Files
parking_solution/wiki/decisions/container-deployment.md
T
julian b1c4109045 docs(wiki): document scripts/booth.sh in container-deployment
Add a "Booth operator wrapper" section (commands, the update flow, env
handling, the volume/ledger safety notes) + a log entry.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-24 20:38:44 +02:00

138 lines
9.3 KiB
Markdown

---
type: decision
tags: [parking, deployment, docker, ci, offline-first]
sources: []
updated: 2026-06-24
status: settled
---
# Container deployment (Docker images for the non-desktop apps)
How the parking system's runtime apps are packaged as containers, tagged, and published.
Settled 2026-06-22. Companion to [[vision-service-packaging]] (which scopes the vision service
into the monorepo) and the desktop [[desktop-shell-tauri]] (a separate, tag-only bundle).
## Two images (the desktop app is NOT containerized)
- **`parking-server`** — the Fastify API **plus the built React SPA**. One container serves both:
Fastify serves `apps/web/dist` via `@fastify/static` (wired in `apps/server/src/static-spa.ts`),
with an SPA fallback to `index.html` for client routing. This matches [[offline-first]] — the
booth appliance is one box, not a web host + an API host. `@fastify/web` static serving is a
**no-op in dev** (no build dir → the Vite dev server serves the UI), so local DX is unchanged.
- **`parking-vision`** — the Python/uv ANPR service ([[opencv-anpr-service]]). Ships WITH the
`alpr` extra (real fast-alpr/onnxruntime stack); the engine is env-selected
(`VISION_RECOGNIZER=stub|fast_alpr`, default `stub` so it boots anywhere). Model weights are
**pre-warmed at build** (best-effort) so the appliance's first scan needs no network.
The **desktop** app stays on its own tag-only `release.yml` (Tauri installers), not these images.
## Branch-aware (the user's hard requirement)
- **Image tags = branch + short SHA.** A push to `dev` builds `…/parking-server:dev` +
`…/parking-server:dev-<sha>`; `main` builds `:main` + `:main-<sha>`. The moving branch tag is the
deploy pointer; the branch-SHA tag is the immutable record. Same for `parking-vision`.
- **Per-env compose.** A base `docker-compose.yml` + overrides: `docker-compose.dev.yml` (build
locally, expose ports, `stub` recognizer) and `docker-compose.prod.yml` (pull pinned images,
`restart: always`, `fast_alpr`, vision kept internal). `REGISTRY`/`TAG` come from env, so a deploy
on a branch pulls that branch's image — the branch→environment mapping IS the override file.
## Booth operator wrapper — `scripts/booth.sh`
So the on-site operator runs one command instead of the long `docker compose -f … -f … --env-file …`
line, **`scripts/booth.sh`** wraps the base + override + env-file. **Prod by default** (the booth is
prod); `ENV=dev` switches to the dev override.
- `./scripts/booth.sh up` — start (detached). `down` / `restart` / `status` / `logs [service]` /
`pull` / `config` / `exec <svc> …` as expected.
- **`./scripts/booth.sh update`** — the "**I know there are new images**" path: `compose pull` the
moving branch tag, then `up -d --remove-orphans` (recreates only services whose image digest moved;
**named volumes — the SQLite ledger — are preserved**), then `docker image prune -f` to reclaim the
old layers. This is the routine update after a `dev`/`main` push republishes the branch tag.
- **Env handling.** Reads **`.env`** (copy from `.env.example`: `REGISTRY`, `TAG`, `JWT_SECRET`,
`EVENT_SIGNING_KEY`, `COOKIE_SECURE=0`, `WS_ALLOWED_ORIGINS`). Prod **refuses to run without
`.env`** (no safe `JWT_SECRET` default — `auth.ts` rejects weak ones). Dev with no `.env` injects
the documented benign local secret so `up` works out of the box. The base file makes `JWT_SECRET`
shell-required (`${JWT_SECRET:?}`), so the env-file is mandatory for both — the script surfaces that
early with a clear message rather than a raw compose interpolation error.
- **Safety:** `down` never passes `-v` (deleting `parking-data` would wipe the signed
[[append-only-event-chain|ledger]]); `help`/unknown-command short-circuit before any Docker/.env
requirement. The operator never types `JWT_SECRET` on the CLI — it lives in `.env` (the user
generates it with `openssl rand -hex 32`).
## Registry + CI
- Published to the house **Gitea registry** `git.infra.msai.al/mca/parking_solution/{parking-server,
parking-vision}`. Login via `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` secrets.
- New workflow **`.gitea/workflows/build-images.yml`** (separate from the checks-only `ci.yml` and the
tag-only `release.yml`): on push to `dev`/`main`, run the full `turbo build lint test` first (don't
ship a broken image), then buildx + `docker/build-push-action` for both images with branch+SHA tags
and a registry build cache. An optional Komodo redeploy webhook is guarded behind a `KOMODO_ENABLED`
var (mirrors the house `trm/processor` pattern). The vision checks need `uv` (the `astral-sh/setup-uv`
step), same as `ci.yml`.
## Build specifics that bit us (record so they don't recur)
- **`pnpm deploy --legacy --prod`, NOT `pnpm prune --prod`.** It's a pnpm/turbo monorepo; pruning at
the root leaves `packages/db/node_modules` empty, so the native **`better-sqlite3`** binding can't
resolve at runtime. `pnpm deploy` produces a self-contained, hoisted bundle (the workspace packages'
built `dist` + their native deps) — a single `COPY --from=build /deploy ./`. pnpm 10 needs `--legacy`
(or `inject-workspace-packages`).
- **Native modules**: Alpine build stage needs `python3 make g++` (node-gyp for better-sqlite3);
runtime needs `libstdc++`. `bcrypt` ships a `linux-x64/musl` prebuild, so it works on Alpine as-is.
- **`pnpm prune`/deploy refuse to run without a TTY** unless `CI=true` (or `ENV CI=true`) is set in
the build stage.
- **Migrations at boot, not at build.** The DB lives on a mounted volume (`/data`), so the entrypoint
runs them against the live file via a **drizzle-kit-free** runtime migrator
(`packages/db/scripts/migrate-runtime.mjs`, using `drizzle-orm/.../migrator` — drizzle-kit is a
devDep, pruned from the prod bundle). Idempotent: a restart re-applies nothing.
- **JWT_SECRET** must be a real value at deploy — `auth.ts` rejects anything `<32` chars or matching
`change.?me|insecure|dev-only`, so the dev compose default is a benign 32-char string, not a
"dev-only…" placeholder (which would crash boot).
- **Vision model pre-warm must run AS the runtime user.** fast-alpr's `open-image-models` caches
weights under `$HOME/.cache/open-image-models` keyed to `$HOME` — it ignores `HF_HOME`/
`XDG_CACHE_HOME`. A first attempt pre-warmed as root (`/root/.cache`), so the non-root runtime
re-downloaded at boot (offline-first BROKEN). Fix: create the `vision` user first, `USER vision`,
THEN run `python -c "from fast_alpr import ALPR; ALPR()"` so weights land in `/home/vision/.cache`
— exactly where the runtime reads. Verify the boot log shows NO "Downloading …onnx".
## Web access — relative API + Caddy proxy (2026-06-23)
- **The server-image SPA uses a RELATIVE `/api` base** (no baked origin), so the UI works loaded
from any hostname/IP. The Dockerfile empties `VITE_API_BASE` via `apps/web/.env.production.local`
before the web build — because Vite auto-loads `apps/web/.env.production`, which sets
`VITE_API_BASE=http://127.0.0.1:3000` for the **Tauri desktop** build only. Without the override
the browser bundle baked `127.0.0.1:3000` and failed Same-Origin Policy from any other host. **Do
NOT bake the domain via a build var** — relative means naming is controlled by hosts/DNS at deploy,
never a rebuild.
- **A Caddy reverse proxy** (prod override) publishes `:80` → `server:3000` (server is `expose`-only,
internal); `/api/ws` upgrades pass through. `Caddyfile` binds `:80` so it matches ANY host — booth
IP, localhost, or `parksystems.msai.al` (pointed at the booth IP via hosts/DNS on-site). TLS later:
swap `:80` for the real hostname + uncomment Caddy `:443` → auto-HTTPS.
- `WS_ALLOWED_ORIGINS` (env) must list any REMOTE origin admins use (same-origin always passes).
## Invariants (must hold)
- **Never bake the live DB.** `.dockerignore` excludes `**/parking.sqlite*` (incl. `-wal`/`-shm`/
`.bak-*`) — `pnpm deploy` copies the package dir's files ignoring `.gitignore`, so the
`.dockerignore` (which gates the build CONTEXT) is what keeps the signed ledger out of the image.
The DB is a host-volume asset ([[append-only-event-chain]], [[threat-model]]).
- **SPA serving must not shadow the API** — the fallback is GET-only and excludes `/api`, `/health`;
a missing `/api/*` still 404s as JSON, not the HTML shell.
- **Offline-first** — both images boot + serve with no network (vision default `stub`; `fast_alpr`
weights pre-warmed into the image layer).
- **Non-root runtime**, minimal final image (deploy bundle only; build toolchain dropped).
## Verified on hardware (2026-06-22)
Both images built + smoke-tested locally (Docker 29, buildx):
- **server**: build → run → entrypoint migrates `/data/parking.sqlite`, SPA static serving enabled,
server listens; `/health` 200, `/` + `/booth` serve the SPA (text/html), `/api/nope` → JSON 404;
no `parking.sqlite*` anywhere outside `/data` in the image.
- **vision** (1.8 GB, `--extra alpr`): build pre-warms the YOLOv9 + CCT weights into the image
(`/home/vision/.cache`); run as `fast_alpr` → `ready:true` with **0 downloads at boot** (offline-
first confirmed); `stub` mode also boots clean.
- **compose** (`docker-compose.yml` + `.dev.yml`): both containers come up healthy and the server
reaches the vision service over the private network (`wget http://vision:8089/health` from the
server container → 200).