--- type: decision tags: [parking, deployment, docker, ci, offline-first] sources: [] updated: 2026-06-22 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-`; `main` builds `:main` + `:main-`. 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. ## 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". ## 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).