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

9.3 KiB

type, tags, sources, updated, status
type tags sources updated status
decision
parking
deployment
docker
ci
offline-first
2026-06-24 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); 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).