# Deploying the console
`deploy/deploy.sh` is **retired as of 2026-09-18** and is now a stub that prints
the command below. The console is deployed from the sibling `monetize-impl`
checkout, in Rust, under test:
```bash
cd ../monetize-impl
cargo xtask refresh-front --only console # build, ship, swap, restart, verify
cargo xtask refresh-front --only console --dry-run # build and survey, change nothing
cargo xtask refresh-front --only console --no-build # ship what is already built
cargo xtask refresh-front --only console --rollback 20260918-101500
```
`--only console` ships `gunnar-ui-server` and the wasm bundle and nothing of
monetize's — what the script used to do. Without it the same verb refreshes the
whole front VM, monetize included.
The host is `95.111.204.164` and it is the verb's own default; `--host` overrides
it. That default has named a box that no longer existed twice (the tailnet host
until 2026-09-13, `212.147.245.191` until 2026-09-18), and both times the symptom
was an ssh timeout — the failure nobody reads as "wrong target".
The verb's source is `monetize-impl/deploy/front/`, a detached workspace; the SSH
transport is `nornir_git::ssh` (russh), which is the whole reason a bash script
is no longer needed. `src/site.rs` is the never-touch list, `src/verify.rs` is
every gate, and `cargo test` in that directory runs the state machine end to end
against a scripted far end.
**Two things the verb deliberately does not do**, both of which the script did:
* it never runs `gunnar-ui-server --install`. That rewrites `/etc/gunnar-ui.env`,
and that file is on the verb's never-touch list beside the passkey store. The
unit's `ExecStart` already names `current`, so a swap and a restart are enough;
run `--install` on the box by hand when `lifecycle`'s declaration changes.
* it never prunes `releases/`. Keeping every release is what makes
`--rollback <stamp>` work for any stamp rather than only for the last one, and
it is also what will eventually fill the disk.
It also verifies on the box's loopback port rather than through `https://gunnar.rs`:
it proves `caddy` is `active`, and it does not prove TLS serves the new bundle.
## How the swap works
Each deploy lands in `releases/<stamp>` (`deploy.sh` used `<timestamp>-<sha>`) and is
checked **there**, before
anything in use is touched: the binary must be executable and must run on the host,
the bundle must have an `index.html`, and it must contain the exact hashed wasm file
this build produced. Only then does `current` change, by renaming a symlink over
itself — one atomic syscall, with no moment where `current` does not resolve.
This replaces `mv dist dist.old && mv dist.new dist`, which is fine until the second
half does not run. It did not run once, and the host was left with no bundle at all.
All of that is `gunnar_ui_server::release` as of 2026-09-13 — the staging checks, the
swap, the rollback and the prune, in `std::fs`, driven against a temporary tree by
`cargo test`. `deploy.sh` no longer carries it as a heredoc. One thing changed while
porting it: a rollback on a host that has no `previous` is now an ANSWER
(`RollbackRefused::NoPrevious`) and not an error, because a first deploy that does not
come up used to send `deploy.sh` into `no previous release to go back to` from inside
its own failure path.
Afterwards the deploy fetches the page the box serves and the hashed wasm file named
in it. That is the point: a restart that quietly kept serving the old directory still
answers `/health`. `refresh-front` does it twice over — by trunk's content-hashed
name, read out of the served page, and by SHA-256 of the bytes under `current/dist`.
`deploy.sh` maintained a `previous` symlink and rolled itself back when the check
failed. `refresh-front` does neither: it keeps every release and prints the exact
`--rollback <stamp>` command on every path, success included, because
"a half-tested rollback in a destructive tool is how one failure becomes two".
## First-time setup on a host
The script deliberately does not create `/etc/gunnar-ui.env`. That file holds the
admin token and the WebAuthn origin, and a deploy that can rewrite them silently is a
deploy that can lock everyone out. What it does now is **declare** it: the keys, which
of them are required, and the reason for every value live in
`gunnar_ui_server::envfile`, and the binary renders them.
```bash
ssh root@HOST
mkdir -p /opt/gunnar-ui && cd /opt/gunnar-ui
ssh-keygen -t ed25519 -N "" -f console_key # the console's own identity
./gunnar-ui-server --env-template > /etc/gunnar-ui.env
$EDITOR /etc/gunnar-ui.env # replace every REPLACE-ME
chmod 600 /etc/gunnar-ui.env
./gunnar-ui-server --check-env # says every reason an install would refuse
```
Then enroll the console with gunnar, using a key that already holds an admin grant:
```bash
GUNNAR_UI_CONTROL=10.13.8.101:50051 GUNNAR_UI_IDENTITY=/opt/gunnar-ui/console_key \
./gunnar-ui-server enroll --admin-key /path/to/admin_key
```
## The unit
There is no `deploy/gunnar-ui.service` any more. Every service in the suite goes
through skidbladnir — unit generation, install, uninstall — and as of 2026-09-13 this
console does too. The unit is declared in `gunnar_ui_server::lifecycle`:
```bash
gunnar-ui-server --unit # print it, write nothing
gunnar-ui-server --install # write it, enable it, start-or-restart it (root)
gunnar-ui-server --status # active + healthy, the health probe dialling GUNNAR_UI_BIND
gunnar-ui-server --uninstall # stop, disable, erase releases/ and incoming/
```
`--install` is what `deploy.sh` ran on the far side after the swap, so the unit was
written by the release it names. `refresh-front` does NOT run it — it rewrites
`/etc/gunnar-ui.env`, which is on that verb's never-touch list — so run it by hand
when the declaration changes. It reads `/etc/gunnar-ui.env` first and refuses,
having changed nothing, if the file is missing, if a required key is unset, if a
placeholder survived, if `GUNNAR_UI_DIR` does not go through `current`, or if the
file sells and the binary has no paid half in it (below). It then
rewrites that file canonically — skidbladnir owns the `EnvironmentFile=` it is told
about, so every key and value survives and the comments do not. The comments are in
the declaration, which is where they can be kept true.
`--uninstall` erases `releases/` and `incoming/` and nothing else. Not `data/`, which
is gunnar's own repository storage; not `console_key`, `guest_key` or `state.redb`.
An uninstall that took the forge with it would not be an uninstall.
## Selling requires two things, and this page used to name neither
A console that takes money needs the `metered` feature **compiled into the binary**
and the monetize lines in `/etc/gunnar-ui.env`:
```
GUNNAR_UI_MONETIZE=https://monetize.ignalina.dk # the ONE monetize; no default, required once a bearer is found
GUNNAR_UI_PRODUCT=gunnar # the product this estate sells under; no default, required once a bearer is found
GUNNAR_UI_MONETIZE_TOKEN_FILE=/opt/gunnar-ui/monetize-token
GUNNAR_UI_BUY_URL=https://buy.stripe.com/…
```
`metered` is **not** a default feature. Without it the monetize client, the signup
routes and `/welcome` are not compiled at all, `App::signup_offered` is a constant
false, and the session gate answers a paying stranger "log in to use the console".
Found the hard way on 2026-09-12: the front had the env lines and a binary with
neither route, `GET /welcome?session=…` answered 401, and two live card payments
taken on 2026-09-11 had to be refunded. **It was not a stale binary.** `deploy.sh`
ran a bare `cargo build --release -p gunnar-ui-server`, so the box was configured to
sell while the artifact could not, and nothing in the deploy said a word about it.
And found again on 2026-09-18, one layer down: the server had been metered for days
while the BUNDLE beside it was not, because `--features` went to `cargo` and never
to `trunk`. Every money panel is `#[cfg(feature = "metered")]`, so the routes were
registered and the buttons that call them were not compiled.
Four things say it now, and none of them is a claim about the build command:
* **The deploy declares the paid half for BOTH halves of the console.**
`artefact::GUNNAR_UI_SERVER` and `artefact::CONSOLE_BUNDLE` carry the same
`metered`, and a test asserts they are equal. Until 2026-09-18 only the first
one did, and `trunk` was handed nothing — see the wasm marker below.
* **The binary stamps its own half.** `gunnar-ui-server --version` ends in
`metered)` or `community)`, and `GET /api/version` carries the same line. The
deploy reads that off the artifact before it uploads anything
(`verify::metered_half_gate`), so `--no-build` cannot ship a community build
left over from another run.
* **The bundle is asked the same question, by route.** A wasm carries no
`--version`, so what is read is `/increase/offer` — the API path
`pages::increase` builds, `#[cfg(feature = "metered")]` on both sides. Not a UI
word: those get rewritten, a route does not. `verify::money_panel_gate`.
MEASURED on the front 2026-09-18: a metered server had been shipping beside a
community bundle for weeks, `/welcome` answered 200, and an admin on
`/usage/<ns>` was shown no way to buy room.
* **`--check-env` refuses the mismatch on the host.** A binary with no paid half,
on a host whose env file sets `GUNNAR_UI_BUY_URL` or a monetize bearer, is
refused by name before the swap — `Refusal::ConfiguredToSellButCannot`. That is
the strongest of the four: it is the staged artifact answering about itself,
against the file it is about to run under.
`GUNNAR_UI_BUY_URL` is the sharp one. It does not go through the feature gate at
all, so a community console with it set advertises a purchase, sends the person to
the payment vendor, takes their card — and then cannot receive them back.
### Two things that will bite
**`GUNNAR_UI_DIR` must be `/opt/gunnar-ui/current/dist`.** Not the unit's business:
systemd applies `EnvironmentFile=` after `Environment=` whatever order they appear
in, so a value in the unit is ignored and the file always wins. Point it anywhere
else and the server starts, answers `/health`, and serves an empty console. The
deploy asks the STAGED binary this before it swaps anything, and refuses.
**WebAuthn needs a real hostname over HTTPS.** Behind `tailscale serve`, the RP ID is
the hostname without the port and the origin includes it:
```bash
tailscale serve --bg --https=8443 http://127.0.0.1:3210
```
A port of its own leaves anything already served at `/` alone.
## When nobody can sign in
A passkey is bound to the relying party it was registered against, so a wrong
`GUNNAR_UI_WEBAUTHN_RP_ID` at the moment the first one is registered — or a lost
authenticator, or a box changing hands — leaves a console that is up, healthy,
serving, and shut.
```bash
systemctl stop gunnar-ui
./gunnar-ui-server reset-state --passkeys # prints, changes nothing
./gunnar-ui-server reset-state --passkeys --yes # does it
systemctl start gunnar-ui
```
Without `--yes` it only prints what it would destroy, including the keys it
would KEEP — which is how you tell whether `--passkeys` or `--all` is the scope
you meant. `--passkeys` forgets the credentials and bumps the session epoch, so
nobody stays signed in on a passkey that no longer exists; the session key, the
per-person revocations and everybody's preferences stay. `--all` empties the
store.
It refuses to run while the console is running, and that refusal is the point:
redb holds the file lock. **Do not `rm state.redb` instead.** On a running
console that removes the name and leaves the open file behind, so the process
keeps serving from a store nobody can see and the next restart quietly starts a
second one — the reset looks like it did nothing until a restart hours later,
when it looks like it did everything.
Afterwards, `GUNNAR_UI_ADMIN_TOKEN` from `/etc/gunnar-ui.env` is the way back in,
exactly as it was on the first day.