# Deployment

> **In practice:** [Tutorial Ch 11](https://rask.sh/docs/guides/11-deploy/) · recipe [deploy and redeploy](https://rask.sh/docs/guides/recipes/#deploy-and-redeploy) · [cheat sheet](https://rask.sh/docs/guides/cheatsheet/).

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:

```bash
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](#scaffolding-a-dockerfile)
Dockerfile below (override with `--dockerfile`).

- **Automatic HTTPS.** With `--domain`, Rask runs a shared [Caddy](https://caddyserver.com) 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](#the-shared-proxy-one-file-per-app).
- **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](https://rask.sh/docs/guides/data/#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](https://rask.sh/docs/guides/configuration/#shutdown-and-redeploy).
- **Running jobs finish where they started.** Once traffic is on the new container the old one claims no new
  [job](https://rask.sh/docs/guides/jobs/#shutdown), 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](#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](https://rask.sh/docs/guides/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`](https://rask.sh/docs/guides/cli/#rask-deploy--ship-to-a-single-host-over-ssh).

### 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](https://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

```bash
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:

```bash
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](https://rask.sh/docs/guides/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`](https://rask.sh/docs/guides/cli/#backup-and-restore):

```bash
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](https://rask.sh/docs/guides/sqlite/#continuous-backup-with-litestream),
which streams the write-ahead log to object storage; it stays inert until you point it somewhere:

```bash
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](https://rask.sh/docs/guides/secrets/).

## 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](#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`](https://rask.sh/docs/guides/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](https://rask.sh/docs/guides/configuration/#raskapps-development-defaults).

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:

```json
{
  "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](https://rask.sh/docs/guides/jobs/#shutdown)) 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`:

```csharp
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:

```csharp
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`](https://rask.sh/docs/guides/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`](https://rask.sh/docs/guides/file-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](https://rask.sh/docs/guides/secrets/):

| 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. |

```bash
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:

```bash
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`).

```bash
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](https://rask.sh/docs/guides/render-modes/#when-websockets-are-blocked) 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](https://rask.sh/docs/guides/spa/#moving-an-existing-app-onto-rask-a-page-at-a-time)).

**Caching in front of the app.** Every page is `Cache-Control: no-store, no-cache, must-revalidate,
private`, because every page is [live](render-modes.md) 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](https://rask.sh/docs/guides/prerendering/), 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](#serving-a-webassembly-app)): 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:

```csharp
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:

```xml
<ProjectReference Include="..\Shop.Client\Shop.Client.csproj"
                  ReferenceOutputAssembly="false"
                  SkipGetTargetFrameworkProperties="true"/>
```

```csharp
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`:

```bash
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`](https://rask.sh/docs/guides/cli/#rask-deploy--ship-to-a-single-host-over-ssh)
  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](https://rask.sh/docs/guides/pwa/#deploying-github-pages--sub-paths).

### 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](https://rask.sh/docs/guides/lifecycle/) instead, so a working page is never replaced by this one.
