Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

The rask CLI

Rask.Cli is a .NET tool that gives Rask a short, task-focused command line on top of the .NET SDK. It generates, or shells out to dotnet, for almost everything it does — its one package reference is SQLite, which rask db backup needs to take a consistent copy of a live database — and it never gets in the way of the tools you already use.

In a hurry? The cheat sheet lists every command on one page, and the recipes answer "how do I do X?" with the command and the wiring line.

Install

curl -sSL https://rask.sh/rask.sh | sh

That puts a rask command on your PATH, along with the .NET SDK and the dependencies the CLI shells out to. Re-run it to upgrade. On a machine that already has the .NET 10 SDK, dotnet tool install -g Rask.Cli installs just the tool, and dotnet tool update -g Rask.Cli upgrades it. Options, install locations and uninstall: Installing Rask.

rask is a thin, Rask-aware layer over the .NET SDK: it owns scaffolding end to end (rask new, and shells out to dotnet for the rest — rask dev wraps dotnet watch, rask db wraps dotnet ef.

Getting help

rask <command> --help (or -h) prints that command's full reference — its arguments, an aligned table of every option with a one-line description, and copy-pasteable examples:

rask                       # on a terminal: the new-project wizard. Piped: the command list.
rask --help                # the command list, always
rask new --help            # arguments, options, and examples for `new`
rask deploy --help

Help (and other output) is colorized when rask is writing to a terminal, and falls back to plain text when the output is piped or when the NO_COLOR environment variable is set — so rask info | cat and CI logs stay clean. Long descriptions wrap to the terminal's width; piped output is never reflowed, so a line you grep for stays on one line.

Short names mean one thing

A short flag means the same option on every command that has it, so muscle memory carries between them:

short option
-h --help (reserved CLI-wide; no command may claim it)
-p --project
-o --output
-n --name (rask new)
-t --template
-f --follow
-y --yes

A few options have no short name on purpose, because the letter belongs to something else: rask dev --open (-o is --output).

--force means overwrite files (rask new). Skipping a destructive confirmation is --yes (rask db drop, rask db restore) — a different word, because it is a different power.

A test enforces all of this, so a new option cannot quietly reuse a letter.

--dry-run and --json

--dry-run lists what would happen and changes nothing, in the same shape everywhere: one [dry-run] would … line per action. It is on new, dev, db and deploy.

rask db drop --dry-run        # the exact `dotnet ef` command, without the database going anywhere
rask dev --dry-run            # the `dotnet watch` command line and the environment it sets
rask new Shop --dry-run                                        # the files it would write

new and dev print the same plan as a document with --dry-run --json:

rask new Shop --dry-run --json | jq -r '.files[]'      # template, name, directory, files
rask dev --dry-run --json | jq '.environment'          # command, arguments, workingDirectory, environment

There --json belongs to the dry run: without --dry-run it is a usage error (exit 2), because neither command has a document to print once it actually scaffolds or starts the app.

A dry run never prompts — it does nothing, so there is nothing to consent to.

--json prints a document and nothing else, so it pipes into jq without filtering banners out:

rask info --json
rask deploy status --json
rask db list --json

Errors still go to stderr and the exit code still distinguishes 2 (you typed something wrong) from 1 (what you asked for failed), so a script never has to parse prose to find out what happened. Fields that have no value are absent rather than carrying a human placeholder — rask info --json on a machine with no SDK simply has no dotnetSdk key, where the human report prints not found.

rask new — scaffold a project

rask                                 # the wizard, from a blank slate
rask new                             # the same wizard
rask new MyApp                       # everything: a server app with the whole stack wired
rask new MyApp -t wasm-hosted        # a WebAssembly app in Client/, served by an ASP.NET host
rask new MyApp -t react              # a React + TypeScript app in client/, served by an ASP.NET host
rask new MyApp -t vue                # the same with Vue — or preact, angular, solid, svelte, lit
rask new Blog --no-push --no-ops     # everything except those two
rask new Tiny --no-data --no-docker  # a lean project, one --no- at a time
rask new Shop --no-tests             # no Shop.Tests project beside the app
rask new Spa --template wasm         # an installable browser-WASM PWA
rask new Shop --islands react        # a server app with a React island beside the C# pages

Batteries are included. rask new MyApp gives you everything the template carries as standard — a SQLite database, CQRS, background jobs, transactional email, a cache, a transactional outbox, file storage for uploads, scheduled backups, a durable log store, the operator dashboard, an installable PWA with Web Push, a Dockerfile, and the localization machinery. Not a sample page to delete: the wiring, ready for your first feature.

One thing is left to you, because it changes what the app is rather than what it can do: where the UI runs — that is the template, not a flag. server renders pages live; wasm-hosted writes them into Client/ as a WebAssembly app an ASP.NET host serves (serving a WebAssembly app); wasm is the same browser app with no backend at all; react, preact, vue, angular, solid, svelte and lit each put that framework's TypeScript app in client/ on the same kind of host, with a typed client generated from your C# messages (single-page app front ends). It used to be a --wasm flag on server, which asked the same question twice — once as a project type and again as a yes/no afterwards.

var app = RaskApp.Create(args);

// Every other battery is on, and every setting lives in appsettings.json under "Rask" (docs/configuration.md).
app.Configure(c =>
{
    c.Jobs.Off();
    c.Push.Off();
});

app.Run<App>();

Turning a battery back on is deleting its line.

Run rask (or rask new) with no project name and — on a terminal — it walks you through a short wizard: the project name, an arrow-key project type picker, styling, whether to add auth, and a battery checklist that arrives fully ticked — space unticks anything you don't want. Pressing enter through it gives you the same project a bare rask new does. It then scaffolds exactly as if you'd passed the flags.

The wizard fills gaps rather than re-asking: anything already on the command line is kept and its question skipped, so rask new --template wasm --no-pwa asks only for the name, and a --no- flag already typed skips the checklist entirely. Piped or in a script (no terminal), a missing name is a plain error instead, and bare rask prints the command list — so automation stays predictable.

Every project also gets a .gitignore, an .editorconfig, a .slnx solution and a global.json, and is initialized as a git repository with one commit — --no-git skips that, and it is skipped automatically inside an existing repository.

A server or wasm app also gets a test project, <Name>.Tests/, listed in the .slnx beside the app, with one passing test — so dotnet test is green before you have written anything. --no-tests leaves it out.

global.json decides which SDK compiles the app, and it names the .NET 10 band:

{
  "sdk": {
    "version": "10.0.0",
    "rollForward": "latestFeature"
  }
}

Without it the SDK picks the newest one installed. On a machine that also carries the next major in preview, that means a net10.0 app is compiled by an 11.0.x release candidate — it builds, tells you so once per build (NETSDK1057), and quietly hands two people on the same repository different compilers. latestFeature takes the newest SDK within the band and never crosses a major, so you get 10.0.400 if you have it and the next patch when you install one.

The version is the band floor, 10.0.0, rather than a real SDK release, and that matters while a band is still in preview: a release candidate sorts below the release it is a candidate for, and roll-forward only ever goes up — so pinning the release would resolve nothing at all on a machine holding the candidate, and allowPrerelease does not rescue it. The floor is satisfied by every SDK in the band.

The trade is that a machine with only a newer major fails outright instead of building on it. Run rask doctor if a scaffold will not restore: it reads this file and names it, rather than reporting a machine with no .NET on it — which is what a pin nothing satisfies looks like from the outside, since dotnet --version is the thing that fails. Delete the file if you would rather track whatever SDK is newest.

Styling is not a choice: every project is Tailwind and the UI kit. The compiler ships inside the host package, so the build compiles Styles/app.css into wwwroot/css/app.css by scanning the project's own source (see Tailwind), and the classes the kit's components write arrive with Rask.Ui — so a C# project gets the whole component vocabulary with no npm and no node_modules. There is no config file and no property that turns it off. --bootstrap and --tailwind are gone, and both are refused rather than ignored, because a flag the CLI accepts and then disregards is the most expensive kind to discover.

Every C# template draws the same starter page, with the UI kit's Flux components and plain Tailwind around them — a Ui.Header with the Ui.Brand, a Ui.Heading, a Ui.Card holding the next steps and a primary Ui.Button, and a footer — so a project looks the same whether it runs on the server or in WebAssembly. The seven front-end templates draw one starter too, in plain Tailwind utilities: a front end brings its own component library. The sign-in pages rask new writes into Features/Auth/ are drawn with the kit too — a Ui.Card of Ui.Inputs, a Ui.Checkbox, a primary Ui.Button and a Ui.Callout for what went wrong — and an app with a database links sign-in from the starter's header.

The CLI writes the project's files itself, pins the Rask.* package references, and runs dotnet restore so the output builds immediately.

React, Preact, Vue, Angular, Solid, Svelte and Lit come two ways: as a template, where the whole client is that framework, or as islands inside a Rask page, scaffolded with --islands (below). The meta-framework templates (Next, Nuxt, SvelteKit and the rest) were removed.

A new project has wiring, not sample code — there is still nothing to delete before you start — and everything it scaffolds follows the vertical-slice layout the guides build on: feature code under Features/<Name>/, cross-cutting code under Features/Shared/.

MyApp/
  MyApp.csproj
  Program.cs                      RaskApp.Create(args).Run<App>() — every battery on
  Dockerfile  .dockerignore       a production image
  appsettings.json                every Rask setting, under "Rask"; logging levels
  appsettings.Production.json     overrides applied when deployed
  Features/
    Shared/App.cs                 the root component every page renders through
    Shared/User.cs                the account type
    Shared/ErrorPage.cs           what a visitor sees when something outside a component throws
    Home/HomePage.cs              a [Route("/")] welcome page that teaches the CLI
    Auth/                         sign-in, registration, sign-out, password reset — your pages to restyle
  Migrations/                     the first migration, which the app applies when it starts
  Resources/Strings.en.json       the text of the UI, compiled into typed members
  wwwroot/                        manifest.webmanifest, icon.svg, offline.html
  Properties/launchSettings.json
  MyApp.slnx                      the app and its tests, for `dotnet test` and your IDE
  MyApp.Tests/
    MyApp.Tests.csproj            references the app, Rask.Testing and xUnit v3
    Features/Home/HomePageTests.cs  renders the home page and checks its greeting

The shell lives in Features/Shared/; the welcome page is its own Features/Home/ slice. The account flows come from the auth battery; the pages in Features/Auth/ that drive them — routed at /login, /register, /logout and so on — are yours. There is no database context to write: RaskAppDbContext maps every aggregate you declare and every battery's tables. Add pages and components to taste — the tutorial shows the shapes.

It runs before you touch it

rask new doesn't stop at writing files. After the restore it creates the first migration for you, and a RaskApp applies its pending migrations when it starts, so:

rask new Shop && cd Shop && rask dev

serves the app — there is no rask db update step. The migration is not a convenience: the database-backed batteries keep their state in tables that only a migration creates, and the app can only apply one that exists.

It reuses rask db to do it, so your project ends up in exactly the state rask db add Init leaves it in — including installing the EF Core tools on first use. --no-restore skips the migration along with the restore, and if it can't complete, rask new says so and prints the command to run rather than failing: the files on disk are correct either way.

Option Meaning
<name> (or --name) The project name. Required.
--template, -t server (default), wasm, wasm-hosted, or a front end: react, preact, vue, angular, solid, svelte, lit.
--no-pwa Leave out the web app manifest, service worker, icon and the wiring to serve them. Takes Web Push with it.
--no-cqrs Leave out Rask.Cqrs, the mediator. Your pages read through the model surface without it, but they write through it: a save is a command whose handler loads the entity and calls SaveChangesAsync. The scaffold's plumbing needs it too — background jobs run through their command handlers, the outbox and Rask.Data's domain events are published through it, and a wasm-hosted or front-end template's client messages arrive through it (so they refuse the flag). So it still takes the database with it, and every battery that maps onto a DbContext (below). It also takes Rask.Query, which rides along with the dispatcher: a dispatcher without a cache refetches on every render, so the cache is not a separate decision and has no flag of its own.
--no-data Leave out the SQLite database (c.Data.Off()): no RaskAppDbContext, no WAL + busy_timeout connection, and no continuous backup (Litestream — otherwise inert until you set Rask:Litestream:ReplicaUrl, so turning it on is one env var at deploy time: rask deploy --env "Rask:Litestream:ReplicaUrl=s3://bucket/app"). Takes every battery that maps onto a DbContext with it.
--no-jobs Leave out durable background jobs (c.Jobs.Off()).
--no-mail Leave out transactional email, delivered off the request thread; the dev default writes .eml files to ./mail-pickup instead of needing SMTP.
--no-cache Leave out the database-backed cache — the standard IDistributedCache plus a typed ICache.
--no-storage Leave out file storage for uploads — a StoredFile row per file on the database, the bytes on disk (or in S3 or Azure, by configuration), and the routes that serve public and temporary links.
--no-push Leave out server-sent Web Push (c.Push.Off()): the subscriber table, Push.Send(…), and the /_rask/push/key, /_rask/push/subscribe and /_rask/push/unsubscribe endpoints. The PWA stays.
--no-snapshots Leave out scheduled point-in-time SQLite backups via the Online Backup API — a second line of defence alongside the continuous backup the database already wires.
--no-logs Leave out the durable log store in a SQLite file of its own, which keeps the application log across a restart — buffered off the request thread, with retention by age and row count. The only battery unaffected by --no-data: it takes a connection string rather than a DbContext, so it needs no migration and works on an app with no database.
--no-ops Leave out the operator dashboard at /_rask over every battery's table — queue depth, dead letters and the error behind each, the log, the live SQLite pragmas. It is gated on the admin role — the one the first account to register holds — because it shows job payloads, stored email bodies and log lines.
--no-docker Leave out the production Dockerfile and .dockerignore.
--no-tests Leave out the <Name>.Tests project — on server and wasm, the templates that scaffold one. It references the app and Rask.Testing and carries one passing test, Home_page_greets_the_visitor, so dotnet test works before you have written anything.
--output, -o Target directory (defaults to a folder named after the project).
--dry-run Print the files that would be created and write nothing (skips the restore, the build and the migration).
--json With --dry-run: print that plan as JSON — template, name, directory, files — and nothing else.
--force Scaffold into a directory that already contains files, overwriting on collision. Without it, any existing file the template would overwrite stops the command.
--no-git Don't initialize a git repository (one is created with an initial commit by default).
--no-restore Skip dotnet restore (for offline use), and with it the build and the first migration — there is nothing to build against. Without it, a restore failure is reported as a failure: the files are written, but the project won't build until it succeeds.

The batteries wire a feature up; they don't scaffold sample pages for you to delete.

The positive flags are gone. --data, --jobs, --ops, --all-batteries and the rest turned something on that is now already on, so they'd be flags the CLI accepts and disregards — the most expensive kind to discover. They're rejected instead, with the answer:

$ rask new Shop --data
--data is on by default now, so there is nothing to turn on. Pass --no-data to leave it out.

$ rask new Shop --all-batteries
--all-batteries is gone: every battery is on by default now. Pass --no-<battery> to leave one out, e.g. --no-push.

Every server app also gets Features/Shared/ErrorPage.cs and app.UseExceptionHandler("/error") outside Development. ErrorBoundary already catches anything thrown inside a component tree; this covers everything outside it, which would otherwise be a bare 500 with an empty body. The page renders through your app shell and shows a correlation id and nothing else — the exception goes to ILogger, where you match it by that id. Locally the handler stays off, because the developer exception page is strictly more useful than a page designed to reveal nothing.

Which template supports which flag

A template gets every battery in its column, and nothing outside it. Nobody maintains a per-template default list: the default set is the column.

Battery server wasm wasm-hosted front ends⁴
database, CQRS ✅ — ✅ ✅
jobs, mail, cache, storage, outbox, snapshots, logs ✅ — ✅ ✅
ops (the operator dashboard) ✅ — ✅¹ ✅¹
PWA ✅ ✅ ✅ ✅³
Web Push ✅ — ✅ ✅³
Docker ✅ ✅ ✅ ✅
localization (in appsettings.json, not a flag) ✅ —² —² —
--islands <runtime>… (opt-in) ✅ ✅ ✅ —

¹ The operator dashboard is Rask components, so it needs a host built on Rask.Server to mount it. wasm-hosted and the front ends are RaskApp.Create(args).Serve(), which serves the browser app and server-renders the dashboard at /_rask.

³ On a front end both are the client's own files: client/public/manifest.webmanifest and rask-sw.js, and client/src/push.ts, which subscribes through the browser's PushManager and the host's /_rask/push endpoints.

⁴ react, preact, vue, angular, solid, svelte and lit — one column, because the host is the same. Only the client differs.

--islands — a React, Vue, Svelte, Solid, Lit, Angular, Preact or Blazor component

rask new Shop --islands react                  # one runtime
rask new Shop --islands react angular blazor   # several at once
rask new Shop --template wasm --islands vue    # in the browser-WASM host too

Each runtime named gets a paired .cs and front-end file in a folder of its own, its npm dependencies merged into one root package.json, the tsconfig mapping that makes @rask/<Name>.props resolve, and the package reference it needs. The home page renders each one — ReactCounter.Caption("React island") in Features/Home/HomePage.cs — so rask dev shows it working before you have written anything. The base class is the declaration; see Islands.

One combination is refused by name, because the build would refuse it later: react and preact together. @vitejs/plugin-react resolves Babel 8 while @preact/preset-vite pins a @babel/core 7 peer, so npm will not install both. A Preact component can also be rendered by ReactComponent if the app aliases react to preact/compat.

blazor is the one kind with no npm side at all: the Razor SDK compiles the .razor and Rask renders it server-side into the first response, so a Blazor-only island project gets no package.json and never probes for Node.

² Languages are configured, never chosen on the command line — there is no --culture and no --no-localization. Every C# template starts with English in Rask:Cultures — appsettings.json on server and wasm-hosted (the host's file, which the browser half's build reads too), wwwroot/appsettings.json on wasm — and an English Resources/Strings.en.json (Client/Resources/ on wasm-hosted).

In the browser a language other than English is not free: culture data is roughly a megabyte of extra download — on the WASM showcase a published trimmed bundle goes from 3.28 MB to 4.33 MB brotli (+32%). So the build ships it only when the list names one, and adding "hu" is the whole edit. See localization.

The wizard only offers what the chosen template supports, so an interactive run cannot assemble a combination that is then rejected. On the command line, turning off something a template never had is a usage error that names both halves:

$ rask new X --template wasm --no-data
Template 'wasm' has nothing to change for: --no-data. It supports: docker, pwa.

The database-backed batteries need an ASP.NET host to put a database in, which server, wasm-hosted and the front-end templates are — a pure browser-WASM app has none.

Turning one off takes its dependents with it, so you never end up with a battery whose database isn't there:

rask new Shop --no-data     # …and no jobs, mail, cache, storage, outbox, snapshots or dashboard
rask new Shop --no-cqrs     # …and no database either — writes, jobs, the outbox and domain events run through the mediator
rask new Shop --no-pwa      # …and no Web Push, which subscribes through the service worker
rask new Shop --no-logs     # …and nothing else: the log store owns a database of its own

On server, the order they are wired in — load-bearing rather than stylistic, like the Litestream restore before anything opens the database, or authentication before MapRask — lives in RaskApp, not in your Program.cs, and is pinned by tests rather than left to chance.

Turning off a battery a template doesn't have (for example --no-cqrs on wasm) fails fast with the list of what that template does support, rather than passing an unknown option through.

Writing code — by hand, from the guides

rask scaffolds a project; it does not scaffold code inside one. There is no rask generate.

Pages, components, CRUD slices, background jobs, emails and cached accessors are all ordinary C# you write yourself, and every one of them is documented as code you can copy:

What you want Where the code is
A routed page, a reusable component Composition, Routing
A CRUD slice — entity, commands, queries, pages Tutorial ch.2, Rask.Data, CQRS
A background job Tutorial ch.4, Jobs
A transactional email Tutorial ch.5, Mail
A cached read Tutorial ch.6, Cache
Domain events through the outbox Tutorial ch.7, Outbox

The tutorial builds all of it in order, one chapter per pillar, so a snippet that needs its surroundings has them by the time you reach it.

Why no generator? Scaffolded code is read far more often than it is written, and a generator's output has to be understood line by line the first time you meet it anyway. Teaching the same code in the guides means there is one version of it — the one you can read, adapt, and keep — instead of a generated one plus a document describing it, drifting apart.

When you get it wrong

A rejected command line always names what was wrong, what is allowed, and what to run next — and, where there's an obvious candidate, what you probably meant:

$ rask deplyo
Unknown command 'deplyo'. Did you mean 'deploy'?

$ rask new Shop --template srever
Option '--template' does not accept 'srever'. Did you mean 'server'? Choose one of: server, wasm, wasm-hosted, react, preact, vue, angular, solid, svelte, lit.
Usage: rask new <name> [options]
Run 'rask new --help' for details.

$ rask db
Specify a 'rask db' action: add, remove, list, update, drop, backup, restore.

-h is --help for every command, so no option has -h as a short name (rask deploy --host has no short form). Anything after -- is your app's, so rask dev -- --help passes it through.

Exit codes

Code Meaning
0 Success.
1 The command ran and what you asked for failed — a build error, an unreachable host, a refused deploy.
2 The command line was wrong — an unknown command, option, action, or value; a missing value; options that contradict each other.
130 Interrupted with Ctrl+C.

The 1 / 2 split is what lets a script tell a broken invocation from a broken deploy. The line between them is where the bad input came from: anything decidable from the arguments alone is 2, while a value that could have come from .rask/deploy.json — or from the state of the disk, the network, or the host — is 1.

rask dev — run with hot reload

rask dev                             # find the project, run it, open https://<name>.test
rask dev --no-open                   # …without the browser
rask dev --open                      # open a browser even on a plain localhost run
rask dev --project src/MyApp/MyApp.csproj
rask dev --urls http://localhost:5005
rask dev -- --my-app-flag            # everything after -- goes to the app

rask dev runs dotnet watch run, so editing a component's Render() (or a scoped .css / .ts) and saving re-renders the open page live — see what hot-reloads below.

It finds the project for you: in a client-plus-host solution it picks the .Server host (the client is built into it).

A host that references Rask.Spa.Hosting and has an npm front end — a client folder, or the one RaskSpaClientDir names — gets two processes: dotnet watch for the host, and the front end's own dev server (npm run dev, or start where that is what its package.json has). The browser opens on the dev server, since that is what serves the app and what its hot reload reaches; the production bundle is not built during the session. See single-page app front ends.

It also sets up the environment the loop needs: ASPNETCORE_ENVIRONMENT=Development when you have not set an environment yourself, and HotReloadAutoRestart so an edit hot reload can't apply restarts the app instead of stopping at an interactive prompt. Pass --no-restart to be asked instead.

https://<name>.test

An app called AppName is served on https://appname.test — a real name, real HTTPS, no port. Nothing to install and nothing to configure: the name comes from the project, and rask dev sets the machine up the first time you run it. macOS, Windows and Linux.

rask dev opens it for you. Not behind --open: that flag is opt-in on a plain localhost run because the URL there is a guess out of a launch profile, while the .test name is one the command just configured the machine for and issued a certificate covering. --no-open still stops it. The one case it stands down is a launchSettings.json whose profile sets "launchBrowser": true — dotnet watch honours that itself and no environment variable suppresses it, so opening as well would give you two tabs every run. The scaffolded templates set it to false for exactly this reason; an older project that still has it true opens the profile's https://localhost:PORT instead, and rask dev says so.

The first run asks for permission once, showing exactly what it will change:

To serve this app on https://appname.test, Rask needs permission once to:
  • trust 'Rask Local Development CA' as a local certificate authority (System keychain)
  • add '127.0.0.1 appname.test' to /etc/hosts
  • redirect port 443 to this app (pf anchor 'com.apple/rask')

Set that up now? [Y/n]

Say no and it serves on localhost as it always did. Every later run is silent — the plan is recomputed from the machine each time and comes back empty, so the password belongs to first-time setup rather than to starting an app. The certificate authority is trusted once, ever: every other project you run afterwards gets its own .test name with no prompt at all.

The authority can sign for .test and loopback, and nothing else. A root in the system trust store is believed by every browser on the machine, and its key sits under ~/.rask. So the certificate carries a critical name constraint — .test, localhost, 127.0.0.1, ::1 — and cannot mint a subordinate authority: a copied key yields certificates for your own dev names and for no real site. An authority created before this was added is replaced the next time rask dev runs: one more prompt, naming the replacement, after which the old root is taken back out of the keychain (macOS) or certificate store (Windows); on Linux trusting the new one overwrites it.

.test is not a stylistic choice. RFC 6761 §6.2 reserves it for exactly this and guarantees it is never delegated in the real DNS root, so a dev name can never collide with a site you actually need to reach. The two obvious alternatives are traps: .local belongs to multicast DNS and on macOS is answered by mDNSResponder rather than /etc/hosts, and .dev is a real gTLD on the HSTS preload list — which is what forced Valet off it when Chrome began force-upgrading every .dev to HTTPS.

It is HTTPS only. No plaintext listener is bound, so the live WebSocket is wss://, a Secure cookie behaves in development the way it will in production, and there is nothing on the machine serving the app unencrypted. dotnet dev-certs https cannot provide this — it has no hostname option of any kind and only ever mints CN=localhost — so Rask issues the certificate itself from its own local authority, using .NET's X.509 stack rather than an installed openssl or mkcert.

The certificate covers the name, *.appname.test beneath it, and the loopback names too — localhost, 127.0.0.1 and ::1. Kestrel binds loopback, so those reach the very same app, and a certificate that covered only the name turned every other way in — an older scaffold's launch profile, a bookmark, an IDE's run button, --no-host — into a name-mismatch interstitial on a page that is your app. A certificate issued before this covered the name alone, so rask dev re-mints it on the next run.

Everything it stores lives in ~/.rask/certs, with private keys readable only by you.

What each platform actually does

The three steps are the same everywhere; what carries them out is not. The port is the one that surprises people — only macOS needs a redirect at all, because it alone reserves ports below 1024 from an ordinary process.

macOS Windows Linux
Trust System keychain, via security add-trusted-cert CurrentUser\Root, through .NET's own API — no admin, just Windows' consent dialog The distribution's CA anchors (update-ca-certificates, update-ca-trust, or trust extract-compat), plus NSS for Chrome and Firefox
Hosts file /etc/hosts via sudo install System32\drivers\etc\hosts via a UAC-elevated copy /etc/hosts via sudo install
Port 443 Kestrel on 5001, pf anchor redirects 443 → 5001 Nothing. Kestrel binds 443 directly One sysctl (net.ipv4.ip_unprivileged_port_start), then Kestrel binds 443
Elevation sudo, primed once up front UAC, on the hosts step only sudo, primed once up front

On Linux, port 443 is opened with a sysctl rather than a firewall rule on purpose: nftables and iptables are actively managed by docker, ufw and firewalld, and injecting a redirect into them is far likelier to collide with something you depend on. The trade-off is that the sysctl is machine-wide — any unprivileged process may then bind 443 and up — which is why the prompt names it explicitly. It resets on reboot.

Linux also needs certutil (from libnss3-tools) for Chrome and Firefox, which consult NSS databases rather than the system anchors. Without it the system trust still works — curl, wget and .NET are fine — and rask dev tells you what to install.

Firefox on macOS and Windows. It keeps its own certificate store and never reads the operating system's, so it will warn on appname.test there until you add the CA to it by hand (Settings → Privacy & Security → View Certificates → Authorities → Import ~/.rask/certs/rask-local-ca.pem). On Linux certutil handles it for you.

To undo it: delete the # >>> rask dev >>> block from your hosts file, and remove Rask Local Development CA from Keychain Access (macOS), certmgr.msc → Trusted Root Certification Authorities (Windows), or the anchor directory plus certutil -D (Linux). The macOS pf anchor and the Linux sysctl both reset on reboot; Windows leaves nothing behind but the two above.

When it stays on localhost. It never fails a dev loop over a URL — any step that does not work falls back to http://localhost:5000 with a note. It is also skipped by design when:

Situation Why
--no-host, or RASK_DEV_NO_HOST is set You asked for localhost.
--urls was passed You named the addresses to listen on; serving somewhere else would be the opposite of helpful.
Not macOS, Windows or Linux There is no implementation of the three steps for that platform.
No terminal (CI, a piped run) The prompt would have nobody to answer it.
The project has islands Islands load from a second dev server over HTTP, which an HTTPS page is not allowed to do at all (mixed content).

The last one needs its bundler taught to serve TLS before this can cover them; until then it keeps the localhost URL that does work.

One app at a time gets port 443. Only one process can hold it, and on macOS the pf redirect points at whichever rask dev is currently running (pf matches on address and port — it cannot see a hostname, let alone a TLS SNI). Two dev servers at once is the case this cannot serve on any platform.

Flag What it does
--project, -p Project to run. Accepts a .csproj or a directory.
--urls URLs to listen on (sets ASPNETCORE_URLS).
--launch-profile launchSettings profile to use.
--open Open a browser once the app answers. Implied when the app is on its .test name. Skipped if the launch profile already opens one.
--no-open Never open a browser.
--no-hot-reload Keep watching, but restart on change instead of applying live.
--no-restart Ask before restarting on an edit hot reload can't apply.
--once Run once without watching (a plain dotnet run).
--no-banner Suppress the startup banner.
--no-host Serve on localhost instead of this project's https://<name>.test address.
--dry-run Print the dotnet command that would run, where, and the environment it sets — and start nothing.
--json With --dry-run: print that plan as JSON — command, arguments, workingDirectory, environment — and nothing else.

Changed in this release. --no-hot-reload used to mean "a plain dotnet run" — it stopped watching altogether, and cleared DOTNET_WATCH, which is what the framework keys its own dev-time behaviour off. It now means what it says: keep watching, restart instead of applying edits live. Use --once for the old behaviour.

What hot-reloads

C# Hot Reload applies new IL to the running process; Rask then refreshes what the generators registered at startup and repaints every open session. Some edits the runtime cannot apply at all — those are rude edits, and rask dev restarts the app for you and the browser reloads itself.

Edit What happens
A component's Render(), or anything it calls ✅ Applied live; the page repaints in place.
A scoped .css / .ts sibling ✅ Applied live; the bundle URL changes and the <link> is swapped.
Deleting a scoped .css ✅ The rules disappear from the page.
A [Route] template ✅ The route table is rebuilt.
A CQRS command/query/event handler body ✅ The next dispatch runs the new code.
A job type's or durable handler's body ✅ Applied live.
Adding or removing a type — a new component, page, handler, job ⚠️ Rude edit → the app restarts, and the browser reloads itself.
Changing a signature — a new required prop, a changed method signature ⚠️ Rude edit → restart.
Renaming a job type ✅ Applied. The old name stops resolving too.
An island's .tsx / .vue / .svelte ✅ Hot-replaced by its own framework — see below.

Islands hot-reload too, on a second dev server. When the project has islands, rask dev starts Vite for them on http://localhost:5174 and skips only the production island bundle. Everything else still runs, including the prop type-check.

How much survives a save is the framework's call rather than Rask's: React, Preact, Solid, Vue and Svelte keep component state through their own refresh integrations, while Lit and Angular have none and fall back to a page reload. Even those skip the C# rebuild, which is the slow half. See islands.

One thing it does not cover:

  • A rude edit is not announced. dotnet watch restarts the process, so nothing in Rask observes the edit; what you see is the app coming back and the page reloading.

The database is not a source edit. dotnet watch reacts to any file that appears in the project folder, not only to what the project compiles, and SQLite writes app.db-wal and app.db-shm beside the database every time the app starts. Rask adds SQLite's -wal, -shm and -journal files (for .db, .sqlite and .sqlite3) to DefaultItemExcludes, the list the watcher skips, so the app's own writes never set off another project load. The database file itself stays an ordinary project item.

WASM is covered — a client-plus-host app hot-reloads under rask dev like a Server one. To make that possible the host serves the client's build output for the session rather than its published bundle: the published bundle is trimmed, and trimming disables the runtime's metadata-update support outright, so no applied edit could ever reach the page. It also drops the nested dotnet publish from the inner loop, which is most of the wait. --no-hot-reload and --once keep the published bundle.

In Development you get a small "Hot reload applied" pill in the corner when an edit lands, so a save that changed nothing visible is distinguishable from one that didn't apply. It is never present in production, and it looks and behaves the same on both transports — Server and WASM share one implementation.

When the build fails

A save that doesn't compile takes the app down — and until now the browser reported that as a network problem: "Reconnecting…", then "Still trying to reconnect…" and a Retry now button that could never succeed. It is a compile problem, and it now says so:

┌───────────────────────────────────────────────────────────┐
│ Build failed                              Stack   Dismiss │
├───────────────────────────────────────────────────────────┤
│ 2 build errors                                            │
│ Features/Products/ProductPage.cs(31,13): error CS0103:    │
│ The name 'titel' does not exist in the current context    │
└───────────────────────────────────────────────────────────┘

Fix the file and it disappears on its own — the reconnect keeps running underneath the panel, so the app comes back the moment it compiles. No reload, no clicking anything.

How it reaches the browser. Nothing in the app can report this, because the app is what died. So rask dev reads dotnet watch's output as it passes it through to your terminal, and serves what it learned from a small read-only endpoint on 127.0.0.1 that it owns for as long as the session lasts. Its URL is stamped onto every page the app serves (data-rask-dev-status on <body>), which is what lets the browser still ask after the server that sent it has gone. Development only: production HTML never carries the attribute, so there is nothing to poll and nothing to leak. If the endpoint can't be bound, rask dev runs exactly as before — the browser just falls back to the reconnect overlay.

When your code throws

An unhandled exception from an event handler shows the same style of panel — over the running app, which stays mounted, scrolled where it was, with your form input intact. That is the state that produced the bug, so it is the state worth keeping. Dismiss the panel and keep clicking; it counts repeats, so a handler throwing on every click is visible as such.

A fault during render, or in a lifecycle hook, replaces the page, in development as in production: re-rendering the subtree that just threw would only throw again, and a component whose load threw would sit on its placeholder for good. In development that page names the exception, with its stack and source. In production every such fault gets the styled error page (a first response answers 500), and nothing of the exception reaches the browser — no type, no message, no stack: the page carries a reference, and the Error log line carries the same one beside the exception.

If nothing ever applies, suspect the path. dotnet watch produces an empty hot-reload delta — silently, reporting success at every step — when the project path traverses a symlink. rask dev resolves the path for you, so this only bites if you drive dotnet watch yourself; run it against the resolved path (on macOS, /private/var/… rather than /var/…) and edits apply again.

Debugging in VS Code

Every template ships a .vscode/ folder. For one with an ASP.NET host, F5 in VS Code runs the app under the C# debugger (the wasm template debugs in the browser — below): breakpoints hit from the first line of Program.cs, and a handler or async lifecycle hook that throws stops the debugger at the fault instead of only showing the panel above. It needs the C# Dev Kit extension, which the folder recommends.

Edits need a restart under the debugger (Ctrl+Shift+F5). The runtime refuses to apply a hot-reload update to a process a debugger is attached to, so dotnet watch and a debugger cannot share one — and C# Dev Kit's own debug hot reload reports itself unavailable for this launch, so a saved edit does not reach the running app. Use F5 to find a bug and rask dev to work on the fix: that is the loop that applies edits live.

What F5 does:

Step What happens
Build dotnet build --property:RaskDevSession=true — the same dev-session switch rask dev passes, so islands come from their dev server instead of a production bundle.
Launch The C# debugger runs bin/Debug/net10.0/<App>.dll with the launch profile's settings and Just My Code on.
Dev servers The app starts what rask dev would have started beside it: the islands' Vite on 5174.
Address https://<name>.test when an earlier rask dev already set that name up on this machine (with :5001 on macOS, where the pf redirect cannot be checked without root); the launch profile's localhost otherwise. F5 never prompts and never changes the machine.
Browser The app prints Rask dev: open <url> once it is listening, and VS Code opens it.

None of that runs under rask dev, which starts its dev servers itself, outside Development, or for a build that was not a dev session — a plain dotnet run behaves exactly as it always has. Stopping the debugger kills the app outright, so a dev server it started can be left holding its port; the next F5 ends that leftover before starting its own, and only if it is still the same process it recorded.

Where the debugger stops on a throw. With Just My Code on (the default, and set in launch.json) and User-Unhandled Exceptions ticked in the Breakpoints view, it stops once, on the line that threw. A handler's exception leaves your code for Rask's, which the debugger reports as user-unhandled by itself. An async lifecycle hook's exception reaches Rask through its faulted task instead, where no debugger would notice it, so Rask asks the debugger to stop there (Debugger.BreakForUserUnhandledException); the stack it shows still names your throw line.

Stack frames open the file. In development, every frame of the panel's stack and every compiler error that names a file on your machine is a vscode:// link to that line.

Editor settings. The folder's settings.json is committed too, so everyone who clones the app gets it:

Setting What you get
File nesting A component's paired files (Counter.css, Counter.ts, a Counter.tsx island, a package island's Counter.props.json) fold under Counter.cs in the explorer. Every template.
Tailwind completion Class completion and hover inside Div.Class("…"), from the Tailwind CSS IntelliSense extension the folder then also recommends. Only the templates that compile Tailwind (server, wasm, wasm-hosted).

Your own preferences belong in your VS Code User settings, which a workspace settings.json does not replace.

Recommended extensions. extensions.json is written from what the scaffold holds, and VS Code offers to install the list when the folder opens:

Extension Recommended to
C# Dev Kit, EditorConfig, Error Lens Every template.
Tailwind CSS IntelliSense server, wasm, wasm-hosted — the templates that compile Tailwind.
Vite+ Every Vite front end (react, preact, vue, solid, svelte, lit), and an app with islands that use npm (every --islands runtime but blazor): Oxlint and Oxfmt from its vite.config.
ESLint angular, whose client keeps angular-eslint.
Vue (Official) An app with a .vue island (--islands vue).
Svelte An app with a .svelte island (--islands svelte).

An existing project gets the same setup by copying .vscode/ from a fresh rask new app of the same template and replacing the project name in launch.json and tasks.json. Add these lines to .gitignore, so the four files are committed and the rest of .vscode/ stays yours:

/.vscode/*
!/.vscode/launch.json
!/.vscode/tasks.json
!/.vscode/extensions.json
!/.vscode/settings.json

Code that runs in the browser

C# compiled to WebAssembly and a component's scoped .ts run in the browser, where the C# debugger cannot reach. F5 debugs them there instead: VS Code's built-in JavaScript debugger launches Chrome with an inspectUri through the WebAssembly debug proxy at /_framework/debug, which turns WebAssembly frames back into C#. Breakpoints then hit in .cs and .ts files alike.

Template What F5 does
wasm Starts the dev server in the background (dotnet run --urls http://localhost:5210, whose SDK dev server maps the proxy), then opens the app in a debugged browser.
wasm-hosted Runs the host under the C# debugger as above, and once it prints Rask dev: open, starts a second session: the browser at the launch profile's https://localhost:5001, attached to the proxy MapRaskSpa maps in Development. Both halves stop at breakpoints.
  • The proxy comes from the WebAssembly SDK (BrowserDebugHost.dll, beside its dev server). The client's build records where it is, next to its build manifest, and MapRaskSpa starts it on the first debugger that asks. It listens on 127.0.0.1 and refuses a ?browser= that is not a DevTools socket on this machine.
  • Scoped .ts is debuggable as .ts. A Debug build emits source maps for it, and the scoped-script bundle is served with an index map beside it, so a breakpoint in Counter.ts binds where the browser runs it. A Release build emits none.
  • Chrome by default. Change "type": "chrome" to "msedge" in launch.json to debug in Edge.
  • Code before the debugger attaches is not stopped in — the first lines of Program.Main. Reload the page once the session is up to stop there.

rask db — migrations, and getting the database in and out

rask db add InitialCreate            # create a migration for the current model
rask db list                         # list migrations and which are applied
rask db update                       # apply pending migrations now (the app also does it when it starts)
rask db update 20240101_Init         # migrate up/down to a specific migration
rask db remove                       # undo the last (unapplied) migration
rask db drop --yes                 # drop the database (a dev reset)
rask db backup                       # a consistent copy of the local database
rask db backup --remote -o backups/  # ...of the deployed one, pulled down
rask db restore backups/app-20260805-081500.db --remote

A friendly wrapper over dotnet ef for the everyday migration lifecycle, meant to pair with what your feature code needs. It finds the project for you (the single .csproj at or above the current directory, or — at a wasm-hosted solution's root — its .Server project, as rask dev does; override with --project), and if the EF Core tools aren't installed it installs dotnet-ef globally the first time you run it.

Action Wraps Notes
add <Name> dotnet ef migrations add --output <dir> sets the migrations folder
remove dotnet ef migrations remove undo the last migration
list dotnet ef migrations list show migrations and applied state
update [<target>] dotnet ef database update apply pending, or migrate to a named point. A RaskApp applies pending migrations itself on start, so this is for rolling back, for a hand-wired host, and for an app that turned that off
drop dotnet ef database drop drops the database; prompts unless --yes
backup — a consistent copy; --output/-o a file or directory, --remote for the deployed one
restore <file> — replaces the database with a copy; prompts unless --yes

Shared options: --project/-p (the project owning the DbContext), --startup-project/-s (the app that configures it; defaults to --project), and --context/-c (when the app has more than one DbContext). Anything after -- is forwarded to dotnet ef verbatim (e.g. rask db update -- --verbose).

The EF Core tools need the startup project to reference Microsoft.EntityFrameworkCore.Design, and rask db adds it for you (via dotnet add package) if it's missing. A project from rask new already has it, because the first migration rask new runs goes through this same code path. backup and restore need none of that: they copy a database rather than migrate one, so they never install dotnet-ef.

Backup and restore

rask db backup                                  # ./<app>-20260805-081500.db
rask db backup --output backups/                # into a directory, same generated name
rask db backup --output nightly.db              # a name you choose
rask db backup --remote                         # the deployed database, pulled down
rask db restore nightly.db                      # replace the local database
rask db restore nightly.db --remote --yes     # ...and the deployed one, unattended

A file copy of a live SQLite database is not a backup. With WAL on — and every Rask app has it, it is one of the production pragmas — committed transactions live in the -wal sidecar until a checkpoint, so the .db file on its own is torn or stale. Both paths go through SQLite instead: locally via the Online Backup API, remotely via VACUUM INTO. Either way what lands is a single self-contained file with the WAL already folded in, taken while the app keeps serving. Locally, the database is the file named by Rask:ConnectionStrings:App in the project's appsettings.json (comments and trailing commas are fine — it reads the file as JSONC).

The remote path needs nothing installed on the host. It runs the copy inside a throwaway container mounted on the app's data volume — the same shape the deploy's readiness probe uses — and brings the result down over the existing docker -H ssh://… connection. The host does need to be able to pull alpine, which it already does for every deploy. Host and app name come from .rask/deploy.json, so a repeat backup is a bare rask db backup --remote; override with --host and --app.

Uploaded files go with the database. When the app keeps stored files on disk, backup archives them beside the copy — nightly.db comes with nightly.files.tgz — and restore puts both back when it finds the archive next to the file you name. The archive is taken after the database copy, so every row in it has its bytes; locally the files are the disk root the app would use (Rask:Storage:Disk:Root, else storage/), and remotely they are /data/files on the volume. A restore unpacks the archive before it touches the database, so a corrupt archive stops it with nothing replaced. With no archive beside it, the files are left as they are, and restore names any rows that now point at bytes that aren't there.

Restore replaces a database, so it behaves like rask db drop: it asks first, takes --yes to skip the prompt, and refuses outright when there's no terminal to ask on rather than guessing. A remote restore also stops the app first and starts it again afterwards — within the app's "stopTimeout", finishing the jobs it is running and saying what it waits for, as a deploy does — because replacing the file under a live writer leaves the running process holding the database it thinks it has, and its next checkpoint writes that belief back over the restored one. If it can't stop the app, it refuses. The stale -wal/-shm sidecars go with the old file for the same reason: left behind, SQLite replays them over the restored database.

Backups are a copy at a moment; Litestream is continuous replication for when the box dies. They answer different questions — "let me look at what production has" and "the server is gone" — and an app that matters wants both.

rask deploy — ship to a single host over SSH

rask deploy --host root@box --domain app.example.com      # bare VPS → live HTTPS site (sets the box up first)
rask deploy --host deploy@box --port 8080                 # no domain: publish a port, bring your own TLS
rask deploy                                               # redeploy: host/domain remembered
rask deploy --github-actions                              # write a workflow that deploys on push to main
rask deploy --dry-run --host deploy@box --domain app.example.com   # print the docker commands, run nothing

One command builds your app's Docker image on the box and runs it. Every deploy step is docker -H ssh://<host> …, so there's no registry, no local Docker daemon, and no image tarball to copy — the build context ships to the host's daemon over SSH and builds there. It deploys the Dockerfile that rask new scaffolds (point at another with --dockerfile).

Handed a box that isn't ready, it sets it up — installs Docker, creates a non-root deploy login with your keys, configures a firewall (one that covers Docker's published ports, which ufw does not reach on its own), and hardens SSH — after showing you the list and asking once. So a fresh VPS goes live without you opening an SSH session. It's idempotent (a ready box is left alone, with no prompt), and nothing that could lock you out happens until a fresh connection has proved the new login works — with a rollback timer on the box as the backstop. See deployment.md for the full story.

With --domain Rask runs a shared Caddy reverse proxy on the box that fetches an automatic Let's Encrypt certificate, so you get a live HTTPS site with nothing else to configure. Deploys are zero-downtime: the new container starts alongside the old one (blue-green), is waited on until its container is running and answers an HTTP health check (GET /health by default — the endpoint rask new scaffolds), then Caddy is reloaded to point at it and the route is checked through the proxy before the old one is removed. If the new container fails to start, fails its health probe, or isn't what the proxy serves after the reload, the previous version keeps serving. Probe a different path with --health-path, or skip the probe with --no-health-check. HTTP requests are zero-downtime; live sessions re-establish, because a session lives in the container being replaced and cannot hand over. The retiring container announces its shutdown first, so open pages show "Updating…" and reload onto the new one at their previous scroll position, with whatever the user had typed put back — see the shutdown ladder.

Running jobs finish on the container that started them. Once traffic has moved, the old container claims no new job but runs the ones it has to the end; the deploy waits for it — up to "stopTimeout" in .rask/deploy.json ("30m" by default, written there on the first deploy) — and prints what it is still finishing (app-blue is finishing 1 job: AtPump (3m12s) — waited 3m20s of 30m). A container still busy when the budget runs out is killed, and the deploy says so in red. A second deploy of the same app waits for it. A bare --port deploy has no proxy to switch, so it keeps a 20s stop and warns that running jobs need --domain.

Multiple apps share one box. Each app has its own file in the proxy (/data/rask/sites/<app>.caddy), and a deploy writes only its own, under a box-wide deploy lock — deploying a second app (a different --domain), even in the same minute, leaves the first untouched. See the shared proxy. One app can answer on several domains: repeat --domain (or give --domain example.com,www.example.com) and each gets its own certificate, all routed to the same container. The first is the primary — the address emailed links use — and every one of them is remembered (as "domains": [...] in .rask/deploy.json; a single one stays "domain"). A domain another app on the box already serves is refused before anything starts. Without --domain, the app is published on --port (default 8080) and you put your own TLS/reverse proxy in front (there's no zero-downtime swap on a single published port). That downtime is inherent to publishing one port; staying down is not. If the new container fails to start or fails its health check, port mode brings :previous back automatically — the last image that passed the same gate — and still exits non-zero, so a bad image costs you a blip rather than an outage. Use rask deploy rollback to undo a deploy that did come up healthy.

Your database survives redeploys. Each deploy runs a fresh container, so rask deploy mounts a per-app named volume and points the app at it (Rask:ConnectionStrings:App → Data Source=/data/app.db) — the SQLite database persists across container replacements. The old container keeps serving for a moment after the proxy switches (so a request already in flight to it isn't cut), then is stopped gracefully (SIGTERM → its Litestream flush + WAL checkpoint) before removal. The rask new Dockerfile prepares a writable /data; a custom Dockerfile needs RUN mkdir -p /data && chown $APP_UID:$APP_UID /data. Add Rask.SQLite.Litestream to also stream it off the box.

Option Purpose
--host <user@host> SSH target. Required on the first deploy, then remembered in .rask/deploy.json.
--domain <host> Front the app with auto-HTTPS Caddy. Repeat it, or comma-separate the hosts, to serve one app on several domains (--domain example.com --domain www.example.com); the first is the primary. Omit to publish --port directly.
--redirect <host> A name the proxy redirects (308, path and query kept) to the primary domain — '*.example.com' for every subdomain. Repeatable; remembered as "redirects". Needs --domain.
--dns <provider> Prove certificates over DNS (DNS-01), which a *. host needs: azure. Builds rask-caddy:dns-azure on the box and replaces the shared proxy with it once (a few seconds of downtime for every site); later deploys keep it. Not remembered — the proxy carries it.
--dns-env-file <path> The provider's credentials as KEY=VALUE lines (for azure: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP_NAME), needed when the proxy is given the provider. Never stored or printed.
--port <n> Host port when there's no domain (default 8080).
--container-port <n> The port your app listens on inside the container — what the proxy is pointed at and what the readiness probe hits (default 8080, which every rask new Dockerfile uses). Only needed for a hand-written Dockerfile that exposes something else. Remembered in .rask/deploy.json, and recorded on the container so a host running apps on different ports keeps each one's routing correct.
--app <name> Image/container name (default: the project name). The same flag names the app for rask db --remote.
--project <path> · --dockerfile <path> The build context / Dockerfile, if not the current project.
--env KEY=VALUE · --env-file <path> Runtime environment for the app container (repeat --env).
--health-path <path> The path the readiness probe hits before switching traffic (default /health). Remembered in .rask/deploy.json.
--no-health-check Gate only on the container running (skip the HTTP probe) — for apps without a health endpoint. Remembered.
--github-actions Write .github/workflows/deploy.yml (deploy on push to main) and print the secrets to add. Touches no host.
--dry-run Print the exact docker commands without running them.

After it's live — status, logs, rollback

rask deploy status            # what's running on the box (every app, not just this one)
rask deploy logs             # the live container's last 100 lines
rask deploy logs --follow    # ...and stream new ones
rask deploy rollback         # put the previous image back, health-gated

These read the same rask.* container labels a deploy writes, so they describe the box as it actually is rather than as .rask/deploy.json remembers it. They need a host (from the config or --host) and nothing else — no Dockerfile, no build.

status lists every Rask-managed app sharing the box, with its URL or published port, its blue/green colour, and how long it has been up — and tells you whether a rollback is currently possible.

rollback exists for the failure the blue-green swap can't catch. That swap protects you from a release that fails — one that won't start, or won't answer its health check. It can do nothing about a release that starts, answers, and is simply wrong. Each deploy therefore moves the image it replaces to <app>:previous before building, and rask deploy rollback starts that image back up through the same gates a deploy uses (running → healthy → under the deploy lock: reload the proxy → check through it → retire the old container). It then swaps the two tags, so running it again undoes the rollback rather than repeating it.

Option Applies to Purpose
--tail <n\|all> logs Lines to show (default 100).
--follow logs Stream new lines until interrupted.

Options that describe what to deploy (--domain, --container-port, --dockerfile, --dry-run, …) are rejected on these verbs rather than silently ignored — they operate on what is already deployed.

Host setup options — these only matter the first time you deploy to a box:

Option Purpose
--setup-host Prepare the host without asking. Needed when there's no terminal to confirm on.
--no-setup-host Never change the host; fail with instructions instead. What the generated CI workflow uses.
--deploy-user <name> The non-root login to create and deploy as when given a root host (default: deploy).
--no-deploy-user Keep deploying as the --host login instead of creating a non-root one.
--no-firewall Don't configure ufw on the host, and don't put Docker's published ports behind it.
--no-harden-ssh Don't disable SSH password login and root login on the host.

Prerequisites. The Docker CLI installed locally (it's the client for every remote docker call, even though nothing builds on your machine), and key-based SSH to the host so ssh user@box works non-interactively. The host needs nothing else — Docker and the rest are installed for you on the first deploy. Point your domain's DNS A/AAAA record at the host before the first --domain deploy so the certificate can be issued. .rask/deploy.json remembers the host/domain/port for repeat deploys but never stores secrets — pass those via --env/--env-file each time.

Deploying from CI. rask deploy --github-actions writes a workflow that runs this same command on every push to main, and prints the two gh secret set lines it needs (an SSH key and the host's fingerprint). Everything else comes from the committed .rask/deploy.json. It deploys with --no-setup-host: prepare the box once from your own machine, so CI never reconfigures a host.

rask doctor — check before you hit it

rask doctor          # what's here, what's missing, and what only some commands need
rask doctor --json   # the same verdict, for CI

Every probe it runs already existed, each reachable only from the command that needed it — so the way to find out whether your machine could run something was to run it and see where it stopped, halfway through, having already done some of the work.

  ok    rask                0.20.1
  ok    dotnet sdk          10.0.302
  ok    dotnet-ef           installed
  warn  wasm-tools          not installed
                            Every browser-WASM build needs it — the wasm and wasm-hosted
                            template, and `dotnet publish` of either. Fix: dotnet workload
                            install wasm-tools
  warn  node                v24.14.0 (below the 24 LTS line)
                            Islands build on it, but their toolchains raise their own
                            floors over time.
  ok    npm                 11.19.0
  ok    git                 git version 2.50.1
  ok    ssh                 OpenSSH_9.8p1, LibreSSL 3.3.6
  warn  docker              not found
                            Only `rask deploy` needs it — https://docs.docker.com/get-docker/
  ok    project             /src/Shop
  ok    database            SQLite
  fail  .rask/deploy.json   isn't valid JSON: 'o' is an invalid start of a property name…
                            Until it parses, its remembered settings are silently ignored.

All seven things the CLI shells out to, not three. It used to probe dotnet, dotnet-ef and Docker; the wasm-tools workload, Node, npm, git and ssh were each discovered by failure instead (#883). The workload was the worst of them: nothing checked for it anywhere, and a missing one surfaces as NETSDK1147, which reads like a broken machine rather than a missing install.

Two of them compare a version, rather than echoing one. A .NET 9 box used to show a green dotnet sdk row and then fail at the first build, because the row printed whatever string the tool returned. Node is measured against the current Active LTS line.

Warnings aren't failures. Docker missing is fatal to rask deploy and irrelevant to everyone else, so only a genuinely broken thing sets the exit code (1); a machine that can start every command exits 0. Every row added above is a warning for the same reason — dotnet is the one dependency fatal to everything, because every command shells out to it.

It is read-only. It reports; it never installs or fixes. A doctor that quietly installed the tooling it found missing would be doing the thing you ran it to avoid.

One thing it exists to catch: a corrupt .rask/deploy.json used to be swallowed — the loader falls back to defaults, so a typo'd file looked exactly like no file, and the remembered host vanished with nothing said. It now says so in passing, and doctor reports it as a failure.

rask info — environment report

rask info
  Rask CLI         0.17.0
  .NET SDK         10.0.201
  OS               macOS 26.5.1

A quick check when diagnosing a machine: the tool version, the .NET SDK version, and the OS. rask --version prints just the tool version.

rask completion — shell completion

rask completion bash >> ~/.bashrc
rask completion zsh  > "${fpath[1]}/_rask"
rask completion fish > ~/.config/fish/completions/rask.fish

Prints a completion script for bash, zsh, or fish. It's generated from the live command list and each command's option schema, so it always matches the installed CLI — completing command names and their --options. Re-run it after upgrading rask to pick up new commands and flags.

Roadmap

The CLI is the front door to the whole Rask stack — from rask new through rask db to rask deploy, the whole lifecycle lives here. See the development workflow for how the framework is built.

Confirm