Files
parking_solution/wiki/decisions/container-deployment.md
T
julian 8155ff456b
CI / check (push) Successful in 35s
Build & push images / images (push) Failing after 17s
feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
Containerize the two non-desktop apps for the booth appliance. The desktop app stays
on its own tag-only release.yml.

- apps/server/Dockerfile: multi-stage node:22-alpine. `pnpm deploy --legacy --prod`
  (NOT prune — the monorepo native better-sqlite3 won't resolve under a root prune)
  yields a self-contained bundle; build stage adds node-gyp toolchain, runtime adds
  libstdc++; non-root, healthcheck. Migrates the mounted DB on boot via a drizzle-kit-
  free runtime migrator (packages/db/scripts/migrate-runtime.mjs) — drizzle-kit is a
  devDep, pruned from prod.
- apps/server/src/static-spa.ts: Fastify serves the built React SPA (one container
  serves API + UI). GET-only fallback to index.html, excludes /api + /health so it never
  shadows the backend; a no-op in dev (no dist). Registered last in server.ts.
- apps/vision/Dockerfile: uv base, --extra alpr, model weights PRE-WARMED into the image
  as the runtime user so fast_alpr boots offline (0 downloads at runtime). Engine env-
  selected (VISION_RECOGNIZER stub|fast_alpr).
- Branch-aware: docker-compose.yml (base) + .dev.yml (build local, stub, ports) +
  .prod.yml (pull pinned, fast_alpr, vision internal, restart always); REGISTRY/TAG from
  env so a branch deploy pulls that branch's image.
- .gitea/workflows/build-images.yml: on push to dev/main, run the full turbo build+lint+
  test gate, then buildx push both images to git.infra.msai.al/mca/parking_solution with
  branch + branch-<sha> tags (registry cache; optional Komodo webhook behind KOMODO_ENABLED).
- .dockerignore excludes **/parking.sqlite* so the signed ledger is NEVER baked.

Verified locally (Docker 29): server image migrates + serves API+SPA (/health 200, /
+ /booth HTML, /api/nope JSON 404, no sqlite outside /data); vision image boots fast_alpr
with 0 runtime downloads; compose stack healthy with server→vision over the private network.

Wiki: new container-deployment.md; vision-service-packaging open Qs resolved; index + log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 15:07:52 +02:00

6.4 KiB

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

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