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

Deployment

In practice: Tutorial Ch 11 · recipe deploy and redeploy · cheat sheet.

Rask apps are ordinary .NET apps, so every standard .NET hosting path works — dotnet publish behind a reverse proxy, Azure App Service, a systemd unit, or a container. This guide covers containers, which the templates scaffold for you — and rask deploy, which ships one to a single host for you.

One-command deploy — rask deploy

If you just want your app live on a box you own, rask deploy does the whole thing — starting from a VPS that has nothing on it but SSH:

rask deploy --host root@box --domain app.example.com
# → sets the box up (Docker, a deploy login, firewall, SSH hardening), builds the image on it,
#   and serves https://app.example.com with an auto-issued cert

It builds and runs the app on the host over SSH — 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 and builds there. It deploys the scaffolded Dockerfile below (override with --dockerfile).

  • Automatic HTTPS. With --domain, Rask runs a shared Caddy reverse proxy on the box that obtains and renews a Let's Encrypt certificate — a live HTTPS site with nothing else to configure. (Point the domain's DNS A/AAAA record at the host first so the cert can issue.) Every site it proxies also has encode zstd gzip: anything the app sends uncompressed goes out compressed, and a response the app already encoded passes through as it is.
  • Zero-downtime, health-gated. Deploys are blue-green: the new container starts alongside the old, is waited on until its container is running and answers an HTTP health check (GET /health by default), then Caddy is reloaded to point at it and the route is checked through the proxy before the old one is removed. A container that fails to start — or that starts but fails its probe (bad config, a failed migration), or that the proxy does not actually reach — is removed and the previous version keeps serving. See The shared proxy.
  • Migrations apply themselves. The new container applies the app's pending migrations as it starts, before it listens — so the health check cannot pass over an un-migrated database, and there is no separate migrate step to run. One that fails stops the container, and the old one keeps serving. When a pipeline applies migrations instead, pass --env Rask:Database:MigrateOnStart=false. See migrations apply themselves on start. Apps scaffolded with rask new ship the /health endpoint; probe a different path with --health-path <path>, or skip the probe with --no-health-check. HTTP requests are zero-downtime; live sessions re-establish, because a session is a component tree and a DI scope inside that container and cannot hand over to the next one. The retiring container announces its shutdown first, so open pages show "Updating…" and reconnect to the new instance immediately instead of reporting a timeout — reloading only if the host that answers cannot rebuild the page, and then at their previous scroll position, with the fields the user had edited restored. See Shutdown and redeploy.
  • Running jobs finish where they started. Once traffic is on the new container the old one claims no new job, but the ones it is running run to the end — the deploy waits for it to exit, up to the app's "stopTimeout" in .rask/deploy.json (default 30m), and prints what it is waiting for. See the shutdown ladder.
  • No step waits forever. The build may run 45 minutes in all and go 15 minutes without printing a line; past either it is stopped, the deploy says which and shows the build's last line, and the previous version keeps serving. While a build is quiet the deploy says so once a minute (… still building — 12m in, nothing printed for 3m (stops after 15m of silence)). Every other docker call — a tag, a start, an inspect, the health probe, the proxy reload — has five minutes, and the old colour's docker stop has its "stopTimeout" on top. A call that runs out is killed with its ssh session, a new colour that was starting is taken back out, and the deploy fails naming the step ( `docker inspect` did not finish within 5m). A build that legitimately needs longer, or has a step silent that long by design, says so in .rask/deploy.json: "buildTimeout": "1h30m", "buildIdleTimeout": "30m".
  • Durable SQLite database. Every deploy runs a fresh container, so the database can't live inside it. rask deploy mounts a per-app named volume and points the app at it (Rask:ConnectionStrings:App → Data Source=/data/app.db), so your data persists across redeploys; the old container is stopped gracefully (SIGTERM) before removal so in-flight writes are checkpointed first. The rask new Dockerfile prepares a writable /data; a custom Dockerfile needs the same (RUN mkdir -p /data && chown $APP_UID:$APP_UID /data). Pair it with Rask.SQLite.Litestream to also stream the database off the box for machine-loss recovery.
  • Several domains, one app. --domain example.com --domain www.example.com (or --domain example.com,www.example.com) serves the same container on each, every one with its own certificate; the first is the primary, which emailed links use. .rask/deploy.json remembers them as "domains": ["example.com", "www.example.com"] — one stays "domain", as before — so a bare rask deploy keeps all of them. A domain another app on the box already answers on is refused before the deploy starts anything.
  • Redirected names. --redirect <host> (repeatable) is a name the proxy answers with a 308 to the app's primary domain, path and query kept — 308 also keeps the method and body, so a program still POSTing to an old address arrives intact. --redirect '*.example.com' sends every subdomain on, while the names you --domain (example.com, www.example.com) stay the app's: Caddy matches the most specific address first. Remembered as "redirects": [...] in .rask/deploy.json, and refused, like a domain, when another app on the box already serves or redirects the name.
  • Wildcards need a DNS provider. Let's Encrypt issues a *. certificate only over the DNS-01 challenge, which writes a TXT record into the domain's zone. --dns azure --dns-env-file azure-dns.env (the file sets AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP_NAME — a service principal that may edit just that zone) builds Caddy with the caddy-dns/azure module on the box (rask-caddy:dns-azure) and, once the new app answers, replaces the shared proxy with it: every site on the box is down for the seconds Caddy takes to start, so the CLI says so first. The data volume carries every certificate over. Only the blocks naming a wildcard prove over DNS; the rest stay on HTTP-01. The credentials go into the proxy container's environment and the Caddyfile names them as {env.…} placeholders — never into .rask/deploy.json, a label or the output. The proxy is labelled rask.dns=azure, so every later deploy (CI's, with no credentials) keeps it as it is; a wildcard on a box with no provider fails before anything starts.
  • Many apps, one box. Each app is a separate --domain with its own file in the proxy, and a deploy writes only its own, under a box-wide lock — so a second app never disturbs the first, even when both deploy in the same minute.
  • No domain? Omit --domain to publish the app on --port (default 8080) and put your own reverse proxy / TLS in front — see the container sections below.
  • One box, and how far it goes. Scaling has the measured numbers — sessions held and events served — plus where the wall actually is and what to do when you reach it.

Prerequisites: the Docker CLI locally, and a host you can ssh into non-interactively with a key. That's it — you never have to SSH in and prepare the box yourself; see below. The host, domain, and port are remembered in .rask/deploy.json for repeat deploys — a bare rask deploy re-ships. Secrets are never stored there; pass them with --env KEY=VALUE (repeatable) or --env-file <path>. Use --dry-run to print the exact docker commands without running anything. Full flag reference: docs/cli.md.

The first deploy to a bare box

Hand rask deploy a fresh VPS — root, an SSH key, nothing else — and it checks what the box has, shows you exactly what it wants to change, and asks once:

'root@box' isn't ready to deploy to. Rask can set it up:

  • Install Docker
  • Start the Docker daemon
  • Create the 'deploy' login and give it Docker access
  • Enable the firewall (allow 22, 80, 443; deny everything else inbound)
  • Make Docker's published ports obey the firewall (allow 80, 443; deny every other container port)
  • Harden SSH (disable SSH password login and root login)

Set up root@box now? [Y/n]

Say yes and the box is ready; the deploy then runs normally. What it does:

  • Docker — installed from Docker's own get.docker.com script, then enabled.
  • A non-root login. It creates deploy (change with --deploy-user, skip with --no-deploy-user), copies your authorized_keys to it, and adds it to the docker group. .rask/deploy.json is updated to deploy@box, so every later deploy uses it — including after root login is switched off.
  • A firewall. ufw with the ports sshd actually listens on plus 80/443 (or your --port) allowed, everything else inbound denied — including container ports, which ufw does not cover on its own (see below).
  • SSH hardening. Password login off, and root login off — but only once a working non-root login exists to replace it.

Setup only ever happens to a box that can't already deploy. Once Docker runs, rask deploy just deploys — a host that's fine as it is (say, a least-privilege login with no sudo, behind a cloud firewall rather than ufw) is checked and left alone, with no prompt and no nagging on every deploy. The box is the source of truth, so re-running is a no-op. Pass --setup-host to prepare a host anyway — that's the explicit way to add a firewall to a box that's already serving. Opt out per step with --no-firewall / --no-harden-ssh / --no-deploy-user, or refuse to touch the host at all with --no-setup-host.

Nothing takes your access away until a new way in is proven. The deploy login is tested with a fresh connection before anything is hardened; if that fails, the firewall and SSH config are never touched. The firewall and hardening themselves run behind a rollback timer armed on the box — if the CLI can't get back in afterwards (or is killed), the host reverts both by itself after ~5 minutes. And when something can't be done safely, it's skipped out loud rather than quietly: if sshd's real port can't be read, the firewall is not enabled, because a firewall Rask can't prove is safe is worse than none.

On ufw and Docker. Docker publishes a container port by writing its own iptables rules: a DNAT that is filtered through FORWARD, where ufw's INPUT rules never see it. On a stock box that makes ufw deny meaningless for anything docker run -p exposes — ufw status reports a port closed while the internet can reach it. Rask closes that gap as part of setting the firewall up, so the deny you were promised is the deny you get.

It works through DOCKER-USER, the chain Docker consults before its own rules and never writes to itself. Rask points it at ufw's forward rules and then default-denies anything else being forwarded into a Docker bridge, allowing only what this deploy publishes. Three things worth knowing:

  • The rules live in /etc/ufw/after.rules, in a block fenced by ###RASK-DOCKER-BEGIN/-END that is rewritten whenever it goes stale and never touches the rest of the file. That file is where they belong rather than a live iptables call, because raw chains do not survive a reboot.
  • To open another container port, use plain ufw: sudo ufw route allow proto tcp from any to any port 5432. It has to be the port inside the container — Docker's DNAT has already rewritten the destination by the time the firewall sees the packet, so a -p 15432:5432 maps to 5432 here.
  • Rask owns the DOCKER-USER chain on a box it firewalls: the block replaces that chain's contents so nothing can accept ahead of the default-deny. If you manage DOCKER-USER with another tool, use --no-firewall.

Only what's forwarded into a Docker bridge is denied, so a box that also routes for something else (a VPN, say) is unaffected — and containers can still reach out and reach each other.

The shared proxy: one file per app

Every --domain app on a box sits behind one Caddy container, rask-caddy. Its config is split so that no deploy can rewrite another app's routes:

/etc/caddy/Caddyfile            # global options (yours, if you add any), then: import /data/rask/sites/*.caddy
/data/rask/sites/eszaf.caddy    # eszaf's site block and redirects — written only by eszaf's deploys
/data/rask/sites/ta.caddy       # ta's — written only by ta's deploys

/data is the rask-caddy-data volume, which also holds the certificates, so the app files survive the proxy being replaced. A deploy copies its file in beside the live one and moves it over it, so Caddy never reads half a file; a copy or move that fails fails the deploy, and the old file stays.

The deploy lock. Everything apps share — creating the rask network, creating or replacing the proxy, writing a file, reloading Caddy, checking the route and retiring the old colour — happens while the deploy holds the box's lock: a container named rask-deploy-lock (docker refuses a second container of one name, so taking it is atomic; its label names who holds it). The build, the start and the health gate run outside it, so two apps deploying at once build in parallel and only take turns for the switch, which takes seconds. A deploy that finds the lock held says who holds it and waits. The lock removes itself after ten minutes, so a deploy killed while holding it (a cancelled CI job, a dropped connection) cannot wedge the box; to free it sooner, docker -H ssh://<host> rm -f rask-deploy-lock. The container listing used to route and to retire is read again under the lock — never the one taken before the build.

One app, one deploy at a time. Two deploys of the same app (two CI runs, say) would pick the same new colour, so each deploy and each rask deploy rollback also holds that app's own lock, rask-deploy-lock-<app>, from before it moves the image tags until the old colour is retired. A second deploy of the app says who holds it and waits — as long as a deploy may take ("buildTimeout" plus "stopTimeout"), then it stops and prints the rm -f that frees it. Deploys of different apps never wait on each other's app lock. The lock has no fixed lifetime: the deploy process holding it renews it every minute from the machine it runs on — nothing on the box renews it — and it removes itself five minutes after the last renewal. A deploy that fails, hits a deadline or is cancelled releases it on the way out; one that was killed outright (a cancelled CI job) stops renewing it, and it is gone within five minutes.

The check through the proxy. caddy reload only proves the config parses. After it, the deploy asks Caddy's admin API (GET /config/, from inside the proxy's network namespace) which upstream it runs for the primary domain, and requires exactly the new colour (<app>-<colour>:<port>); then it fetches https://<domain><health path> through the proxy by name, as a visitor's request arrives, and requires the app to answer. Before the certificate is issued (DNS not pointed yet, or the ACME order still pending) the TLS handshake cannot succeed, so the request goes one step in instead — the upstream, asked from the proxy's network, must answer. Both are retried for the readiness window. Only then is the old colour retired and Deployed. printed.

What you see when it fails. The deploy puts this app's file back as it was, reloads, removes the new colour, and exits non-zero:

The proxy is not serving eszaf.hu from eszaf-blue: its running config sends eszaf.hu to eszaf-green:8080.
Put eszaf's routes back as they were — the previous version is still serving.
Removed eszaf-blue. The deploy failed; nothing that served before was changed.

If even putting the routes back fails, the new colour is left running (the proxy may still send traffic to it) and the message names the file to check — deploy again once the cause is fixed.

Moving an older box. A box an older Rask deployed has one Caddyfile naming every app. The first deploy of any app with this version moves it — under the lock, once: every other app gets its own file, routed to the container that serves it now (the one the old Caddyfile names; with two colours of one app running and neither named, the newest, with a warning), then the base Caddyfile is swapped for the import and Caddy reloads. Nothing restarts and no app changes colour. If the reload or the check fails, the old Caddyfile is put back. Global options you add above the import line are kept by later deploys. Every machine that deploys to one box must use this version: an older rask deploy still rewrites the whole Caddyfile, and the next new deploy moves the box again. The move writes a file only for an app that has none yet; it never edits one that exists.

Another app's file is never touched. A deploy writes its own app's file and nothing else's — not even when an app has no running container left (stopped by hand, or moved to a bare --port): its file stays, and so do its names. A deploy that asks for a name another app's file still holds is refused before anything starts, naming the file; if that app is gone for good, remove it: docker -H ssh://<host> exec rask-caddy rm /data/rask/sites/<app>.caddy.

Deploying from GitHub Actions

rask deploy --github-actions   # writes .github/workflows/deploy.yml and prints the secrets to add

The workflow runs the same rask deploy on every push to main. Host, domain and port come from the committed .rask/deploy.json, so only two secrets are needed — the command prints the exact gh lines:

gh secret set RASK_SSH_PRIVATE_KEY < ~/.ssh/id_ed25519
gh secret set RASK_SSH_KNOWN_HOSTS --body "$(ssh-keyscan box.example.com 2>/dev/null)"

Pass app secrets to the container by adding --env "Key=${{ secrets.SOMETHING }}" to the workflow's deploy step. The generated workflow deploys with --no-setup-host on purpose: set the box up once from your own machine, where you can see what's about to change — a host that isn't ready should fail the job, not get reconfigured by CI.

App secrets — passwords, API keys, SMTP logins — have their own page: Secrets. .rask/deploy.json records the names of the variables your app needs (never their values), and a deploy that doesn't supply one of them refuses rather than starting the app misconfigured.

Backups

Two different questions, two answers. "Let me look at what production has", or "that migration was a mistake, put last night's file back" — that is rask db backup / rask db restore:

rask db backup --remote                 # a consistent copy of the deployed database, pulled down
rask db restore last-good.db --remote   # stops the app (finishing its jobs), replaces it, starts it again

"The server is gone" is the rest of this section. rask deploy mounts a named volume so the database survives redeploys — but a volume is still one copy on one disk. An app scaffolded by rask new is already wired for Litestream, which streams the write-ahead log to object storage; it stays inert until you point it somewhere:

rask deploy --env "Rask:Litestream:ReplicaUrl=s3://your-bucket/app" \
            --env "AWS_ACCESS_KEY_ID=…" --env "AWS_SECRET_ACCESS_KEY=…"

With that set, a fresh box restores the database from the replica on startup — which is what makes "one server" a reasonable place to keep your only copy. Until it is, every deploy says so:

! No Litestream replica configured — this app's database exists only on this box's disk.

The replica URL is remembered by name like any other variable, so once set, a later deploy that forgets it fails rather than silently dropping it.

What rask deploy sets on the container

Beyond your own --env values, every deployed container gets:

Setting Why
ASPNETCORE_ENVIRONMENT=Production Selects appsettings.Production.json and turns off the developer exception page. Your own --env wins if you set it.
ASPNETCORE_SHUTDOWNTIMEOUTSECONDS=<stopTimeout − 5> The app's own shutdown budget (HostOptions.ShutdownTimeout), cut from the same "stopTimeout" the deploy waits for, so the app gives up before the SIGKILL. See the shutdown ladder.
Rask__ConnectionStrings__App=Data Source=/data/app.db Points the app at the mounted volume, so the database survives container replacement.
Rask__ConnectionStrings__Logs=Data Source=/data/logs.db Same volume, for Rask.Logging's own file. Ignored by an app that doesn't use it.
Rask__Auth__PublicOrigin=https://<domain> (with --domain; the first one when there are several) Where confirm and reset emails point. Outside Development the app never takes it from a request's Host header, so without it those emails are not sent. On a bare --port deploy pass it yourself: --env Rask:Auth:PublicOrigin=http://your-host:8080.
--log-opt max-size=10m --log-opt max-file=3 Docker's json-file logs are unbounded by default; on a one-box deploy a chatty app filling the disk takes down every other app sharing it.
--security-opt no-new-privileges A compromised process can't gain rights through setuid binaries. Nothing a Rask app does needs to escalate.
--restart unless-stopped The app comes back after a reboot or a daemon restart.

Upgrade the rask CLI and the packages together. Those two connection strings moved under Rask (they were ConnectionStrings__App and ConnectionStrings__Logs). A current CLI deploying an app on older packages sets names the app does not read, and an older CLI deploying a current app does the same the other way round — either way the database lands inside the container, and the next deploy deletes it. See migrating from the old keys.

The scaffolded app is set up to match. With --domain the deploy sets Rask__BehindProxy=true, so RaskApp honours forwarded headers (Request.Scheme and the client IP are the visitor's, not the proxy's); a port-mode container is reached directly and leaves them untrusted. It also reports live-session capacity and readiness on /health (so a host that is refusing sessions with 503 — because it is full, or because it is draining — says so rather than answering a bare "up"), and gives itself the shutdown budget the deploy names.

Within that budget the app closes admission, tells connected browsers it is going away, lets in-flight HTTP requests and event handlers finish, closes each WebSocket with a proper 1001 handshake, finishes the jobs it is running, and checkpoints SQLite.

The shutdown ladder

One number decides it: the app's stop budget, "stopTimeout" in .rask/deploy.json — "30m", "90s", "1h30m", between 10s and 24h. The first deploy writes the default there, so it is in plain sight:

{
  "host": "deploy@box.example.com",
  "domain": "app.example.com",
  "stopTimeout": "30m"
}

Every rung is derived from it, so the deploy's wait and the app's own budget cannot drift apart. Hosted services stop concurrently, so each pillar's own grace overlaps the others instead of queueing behind them:

Rung Budget Set by
docker stop -t before SIGKILL — the deploy waits this long stopTimeout (30m) rask deploy, from .rask/deploy.json
App HostOptions.ShutdownTimeout stopTimeout − 5s rask deploy, as ASPNETCORE_SHUTDOWNTIMEOUTSECONDS; 15s when nothing names one (AddRask)
In-flight job until the app's budget ends; no new job is claimed JobsOptions.ShutdownGracePeriod (default Timeout.InfiniteTimeSpan)
Live-session drain 5s RaskServerOptions.ShutdownDrainTimeout
Litestream final WAL flush 10s LitestreamOptions.ShutdownGracePeriod
In-flight email send 10s MailOptions.ShutdownGracePeriod
In-flight outbox message 5s OutboxOptions.ShutdownGracePeriod

What a deploy does with it, step by step:

  1. The new container starts, passes its health check, and the proxy is switched to it under the box's lock. From here every request goes to the new container.

  2. The box's lock is released — other apps' deploys never wait out this app's drain — and, after a 2s pause for the proxy's pooled connections, the old container gets docker stop -t <stopTimeout>: a SIGTERM.

  3. The old container claims no new job, not even the next one of a batch it already holds. The job it is running keeps a live cancellation token, and its lease keeps being renewed, so the new container — already polling the same queue — never starts it a second time. Live sessions, HTTP, email, outbox and Litestream drain within their own rungs, as before.

  4. While it waits the deploy says what for, every 30 seconds, from the line the app logs:

    Stopping app-blue: it claims no new job and finishes the ones it is running (up to 30m, "stopTimeout" in .rask/deploy.json)…
      app-blue is finishing 1 job: AtPump (3m12s) — waited 3m20s of 30m
    

    The old container exits the moment its last job is done — the budget is a ceiling, not a sleep — and is removed.

  5. If the budget runs out first, the app cancels the job's token 5 seconds before the end; whatever is still running when docker stop sends SIGKILL is cut, and the deploy says so in red, naming the job it was finishing. A cut job runs again from the start on the new container, under its usual retry rules (a redeploy does not count a failed attempt).

The deploy holds the app's own lock through the wait, so a second deploy of the same app waits — saying which deploy it waits for — until the old container is gone; it gives up after 45 minutes plus the stop budget, and names the lock to free if no deploy is running.

A bare --port deploy keeps a short stop. There is no proxy to switch, so the old container holds the port until it exits, and a long drain would be a long outage. It is stopped within 20s whatever "stopTimeout" says (the app is handed 15s of it), and the deploy warns that a job it is still running is cut there and runs again on the new container. Use --domain for an app whose jobs must be waited for.

rask db restore --remote stops the app the same way — within "stopTimeout", saying what it waits for, and saying loudly if it had to kill it — before it replaces the database, and starts it again after.

An app with no long jobs pays nothing — its old container exits in seconds. An app whose job can run longer than 30 minutes raises "stopTimeout"; one that would rather cut its jobs short sets JobsOptions.ShutdownGracePeriod (see jobs) or a smaller "stopTimeout".

ServicesStopConcurrently is load-bearing, not a micro-optimisation. Stopped one at a time — the .NET default — those inner graces sum, so whichever hosted service stops last gets none of its grace at all, decided by the order of your AddRaskX calls in Program.cs. Stopped concurrently they overlap. AddRask sets it alongside the timeout for exactly this reason.

AddRask keeps a budget the configuration names (ASPNETCORE_SHUTDOWNTIMEOUTSECONDS or the shutdownTimeoutSeconds key) and only falls back to 15s when nothing does. To choose your own in code, configure HostOptions after AddRask:

builder.Services.AddRask();
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = 45.Seconds);

These are budgets, not guarantees. Work that outlives its rung is cancelled: live sessions are aborted (rask.shutdown.sessions.abandoned), and a job, outbox message or email is re-run from the top on the next boot without counting a failed attempt (rask.{jobs,outbox,mail}.interrupted). Both are logged as warnings, so a budget that is too short for your app says so rather than silently degrading.

One honest caveat of stopping concurrently: Litestream stops alongside a job that is still writing, so the rows such a job writes after Litestream's final flush are replicated by the new container's replicator, which shares the same volume and database file, rather than by the old one. The exposure is narrow: the Docker volume survives redeploys, and Litestream only matters for losing the machine itself.

Your users stay signed in across a deploy

The same volume holds the Data Protection key ring, at /data/keys. This matters more than it looks. Those keys sign the auth cookie, and the default ring is written inside the container — so a deploy, which replaces the container, would mint a fresh ring and invalidate every cookie already issued. Every signed-in user would be silently signed out by every deploy, with nothing in the logs to say why.

AddRask does this for you, and there is nothing to write. When /data exists — which is exactly when rask deploy has mounted the volume — the key ring is persisted to /data/keys and the application discriminator is pinned to the application name. Both halves are load-bearing: the default discriminator is derived from the content root, which differs between the build and runtime images, so a persisted ring alone would still fail to unprotect.

On a plain dotnet run there is no /data, so Rask declines to choose and ASP.NET's per-user development ring applies exactly as before. Two knobs, both rarely needed:

Setting Effect
Rask:DataProtection:KeyPath Put the ring somewhere other than /data/keys.
Rask:DataProtection:KeyPath = "" Opt out entirely, and manage the ring yourself.

To take it over completely, configure Data Protection after AddRask — options setups run in registration order, so yours is the last writer and wins:

builder.Services.AddRask();
builder.Services.AddDataProtection()
    .PersistKeysToAzureBlobStorage(/* … */)
    .SetApplicationName("my-app");

If you're carrying an app that predates this and wrote the block by hand, you can delete it. An app that never had it will sign everyone out once, when the persisted ring first replaces the ephemeral one, and never again.

The log store

Rask.Logging keeps its own SQLite file rather than mapping onto your DbContext, so it needs its own pointer onto the volume — which is what Rask__ConnectionStrings__Logs above is. Without it the log would land in the container's writable layer and be destroyed by the very restart it exists to survive.

Two consequences worth knowing before an incident rather than during one:

  • It is not backed up. rask db backup and Litestream cover app.db. logs.db is deliberately outside that — logs are expendable and high-churn, and including them would make every snapshot and every WAL replication more expensive. Copy the file yourself if you need it archived.
  • It shares the volume's disk. Retention bounds it by age and row count (14 days / 100,000 entries by default), so it cannot grow without limit — but on a small box, size the volume with both databases in mind, and watch rask.logs.dropped to know whether the store is keeping up.

Uploaded files

Rask.Storage needs no variable from rask deploy to find the volume: with no Rask__Storage__Disk__Root set it writes to /data/files whenever /data exists, so uploads survive a redeploy the same way the database does. Where files go beyond that is configuration, passed with --env like any other variable, and the secrets among it are remembered by name:

Variable Effect
Rask__Storage__Provider Disk (the default), S3 or Azure.
Rask__Storage__MaxFileSize The largest file accepted, in bytes (default 52428800, 50 MB).
Rask__Storage__PublicBaseUrl An absolute https URL — a CDN or a public bucket domain — that public file links are built on. Unset, the app serves them itself.
Rask__Storage__Prefix A key prefix, so one bucket can hold several apps or environments (shop-prod/).
Rask__Storage__Disk__Root Where the disk provider writes; /data/files on the volume by default.
Rask__Storage__S3__ServiceUrl, __Bucket, __Region, __AccessKeyId, __SecretAccessKey, __SessionToken, __UsePathStyle An S3-compatible store: AWS S3, Cloudflare R2, Backblaze B2, MinIO, DigitalOcean Spaces, or Google Cloud Storage with HMAC keys.
Rask__Storage__Azure__ConnectionString, __Container Azure Blob Storage.
rask deploy --env Rask:Storage:Provider=S3 \
            --env Rask:Storage:S3:ServiceUrl=https://<account-id>.r2.cloudflarestorage.com \
            --env Rask:Storage:S3:Bucket=shop-files --env Rask:Storage:S3:Region=auto \
            --env Rask:Storage:S3:AccessKeyId=… --env Rask:Storage:S3:SecretAccessKey=…

The same two cautions as the log store apply, and the first is sharper here:

  • Only rask db backup takes the files. rask db backup --remote archives /data/files beside the database copy and rask db restore --remote puts both back, stopping the app while it does. Litestream and snapshots cover app.db alone, and the files' rows are in app.db, so a database recovered that way comes back pointing at uploads that no longer exist — the app logs a warning naming them. For files you can't afford to lose between backups, use S3, Azure or the Database provider. A custom Rask__Storage__Disk__Root outside /data/files is not archived.
  • They share the volume's disk. MaxFileSize bounds one file, not the total, so size the volume for the uploads you expect alongside both databases.

Scaffolding a Dockerfile

Every template drops a production-ready multi-stage Dockerfile (+ .dockerignore) into the generated project:

rask new MyApp                                   # Kestrel app → aspnet:10.0 runtime image
rask new MyApp --template wasm                   # static WASM bundle → nginx:alpine

--no-docker leaves the container files out.

The Dockerfile references your project by name (the template renames Company.RaskServer.dll to MyApp.dll when it scaffolds), so docker build works with no edits.

Server app (--template server)

A server-side Rask app is a Kestrel web server. The scaffolded Dockerfile builds on the .NET SDK image and runs on mcr.microsoft.com/dotnet/aspnet:10.0, which already runs as a non-root user and listens on port 8080 (ASPNETCORE_HTTP_PORTS=8080).

docker build -t myapp .
docker run --rm -p 8080:8080 myapp
# open http://localhost:8080

HTTPS behind a proxy. The app calls UseHttpsRedirection(). Inside the container no HTTPS port is configured, so the redirect no-ops — terminate TLS at your reverse proxy / ingress and forward plain HTTP to 8080. Rask renders and events flow over a WebSocket, so make sure your proxy forwards the Upgrade/Connection headers (most do by default; for nginx set proxy_set_header Upgrade + Connection "upgrade" and HTTP/1.1). A browser that cannot get a socket through falls back to plain HTTP on its own, over a long-lived GET /_rask/stream/{session}. nginx needs nothing for it — it honours the X-Accel-Buffering: no header Rask sends, and the 15-second heartbeat stays inside its default 60-second proxy_read_timeout — but a proxy that ignores both must not buffer that path, and must not compress text/event-stream. Serve over HTTP/2 where you can: a tab on the fallback holds one of HTTP/1.1's six connections per origin. To host under a sub-path, pass app.MapRask<App>(pathBase: "/myapp") and route /myapp/* to the container; the page tells the browser runtime the prefix on its own <script> tag, so no <base href> is needed (details).

Caching in front of the app. Every page is Cache-Control: no-store, no-cache, must-revalidate, private, because every page is live and its document carries a session id that belongs to one visit. A cache or CDN in front must not store pages; it can still cache the content-addressed /_rask/a/* assets and wwwroot files, which are served with their own cache headers. Public content that should be cached belongs somewhere that renders without a session — a prerendered WebAssembly site, or a plain ASP.NET endpoint.

Several instances behind a load balancer need sticky sessions: a page's state lives in the memory of the instance that rendered it, and its connection must reach that same instance — the socket, or on the HTTP fallback the stream and every POST beside it.

A client-plus-host solution

No template scaffolds this shape, and none has since the wasm-hosted template was removed. What rask dev and rask deploy still do is recognise it, so a solution built on that template — or by hand — keeps working. Three projects in one solution: MyApp.Client (the browser-WASM SPA), MyApp.Server (the ASP.NET host that serves it), and MyApp.Shared (a class library both reference).

There is no scaffolded Dockerfile for it, because there is no scaffold. If you maintain such a solution you own its Dockerfile; it needs the wasm-tools workload installed before publishing (the Server host bakes in the browser client), and it runs MyApp.Server on the aspnet runtime image — same port and TLS story as the server app above.

If you are starting today, you want the wasm-hosted template instead (below): one project whose browser app lives in Client/, and dotnet publish emits its bundle into the server's wwwroot, where RaskApp.Create(args).Serve() serves it. That path is scaffolded, Dockerfile included.

Serving a WebAssembly app

rask new --template wasm-hosted writes the browser app into Client/, inside the server's own project, and the server's whole Program.cs is:

RaskApp.Create(args).Serve();

Serve() is Run<App>() without a server-rendered root: every battery, the endpoints the browser app dispatches to (MapRaskCqrs), the operator console at /_rask, and MapRaskSpa() last, answering every other path. A battery the app does without is still one line — var app = RaskApp.Create(args); app.Configure(c => c.Jobs.Off()); app.Serve();. The PWA belongs to the browser app (host.UsePwa in Client/Program.cs), so the server maps no manifest or service worker of its own: a route for rask-sw.js would answer before the bundle's file.

dotnet publish copies the client's bundle into the host's wwwroot, where MapRaskSpa (from Rask.Spa.Hosting) finds it with no arguments and applies what a Rask WebAssembly publish guarantees:

Path Cached for ever A missing file
/_rask/a/* — scoped CSS and JavaScript, named by content hash always 404
/_framework/* — the runtime and your assemblies only when the SDK fingerprinted the name 404
anything else in wwwroot never — it revalidates the index document

index.html is never cached, a request whose Accept asks for something other than HTML gets a 404 rather than the index document, and a precompressed .br or .gz sibling is served when the client accepts it. Runtime files are served with application/wasm and application/octet-stream, and AddRaskSpaHost() compresses both.

By hand — a host that is not a RaskApp, or a client in a project of its own — reference the client project and map the API before the fallback, or the fallback answers the API call with HTML:

<ProjectReference Include="..\Shop.Client\Shop.Client.csproj"
                  ReferenceOutputAssembly="false"
                  SkipGetTargetFrameworkProperties="true"/>
builder.Services.AddRaskSpaHost();

var app = builder.Build();
app.MapRaskCqrs();   // your API first
app.MapRaskSpa();

A host serves one client. Rask:Spa:ImmutablePathPrefixes adds cache-forever prefixes; the ExcludeFromFallback and OnPrepareResponse delegates are set in code, on MapRaskSpa(configure: …). rask dev serves the client's build output rather than its publish, because a trimmed bundle turns hot reload off in the browser.

Standalone WASM SPA (--template wasm)

A standalone Rask SPA has no ASP.NET host — dotnet publish emits a static bundle "to serve from any static-file host." The scaffolded Dockerfile publishes the bundle on the .NET SDK image, then copies it into a tiny nginx:alpine image with a bundled nginx.conf:

docker build -t myapp .
docker run --rm -p 8080:8080 myapp
# open http://localhost:8080

The nginx.conf does four things that matter for a Rask WASM bundle:

  • Listens on 8080, and serves /health — the same container port and readiness endpoint as the server template and a client-plus-host solution, so rask deploy can health-gate and proxy a static bundle exactly like any other Rask app.
  • SPA fallback — try_files $uri $uri/ /index.html; so client-side routes resolve.
  • application/wasm MIME — the browser refuses to streaming-compile the Mono runtime .wasm under any other type.
  • gzip_static on; — serves the *.gz siblings the publish step bakes next to each asset. (Rask also bakes *.br, but the stock nginx:alpine has no brotli module; gzip is universally accepted, so this alone keeps transfers small. Swap in an nginx image with ngx_brotli and add brotli_static on; if you want brotli.)

Because it's just static files, you can equally serve the wwwroot publish output from any CDN or object-storage static host instead of a container. For GitHub Pages / sub-path hosting, publish with /p:RaskPathBase=/my-repo — see Mobile & PWA → Deploying.

When the app doesn't start

A misconfigured static host is the commonest way a WASM bundle fails, and the symptom used to be the worst possible one: the splash screen, spinning, for ever. Nothing in the console, nothing on the page. A 404 on _framework, the wrong MIME type, and a genuinely slow connection all looked identical.

They no longer do. A Rask app that cannot start replaces its splash screen with what went wrong — which step failed, and the exception, verbatim:

This app failed to start. The .NET runtime could not be loaded. Check that the _framework assets are being served, with the correct application/wasm content type.

The full error is in the browser console too. The three failures worth recognising:

What it says What it usually means
The .NET runtime could not be loaded _framework is 404ing, or .wasm is served as application/octet-stream. Check the two nginx.conf items above.
The Rask browser module could not be loaded rask.wasm.js did not reach the client — usually a sub-path deploy without /p:RaskPathBase.
The app finished starting but never rendered The app booted and returned without painting. Check that Program.cs awaits host.Run<App>().

The failure panel appears only before the app has mounted. Once it has, an uncaught error is handled by the root error boundary instead, so a working page is never replaced by this one.

Confirm