feat(deploy): Docker images for server (API+SPA) and vision + branch-aware build pipeline
CI / check (push) Successful in 35s
Build & push images / images (push) Failing after 17s

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
This commit is contained in:
2026-06-23 15:07:52 +02:00
parent 492a08a079
commit 8155ff456b
16 changed files with 647 additions and 1 deletions
+99
View File
@@ -0,0 +1,99 @@
---
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-<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).
@@ -110,3 +110,7 @@ The skeleton is **built and wired** (no recognizer models yet):
weights out of the build entirely (baked into the Docker image instead).
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
defer to the install/hardening pass.
> **Resolved 2026-06-22 → [[container-deployment]]:** the vision service now ships as the
> `parking-vision` Docker image (uv base, `--extra alpr`), model weights **pre-warmed into the image
> layer** at build (offline-first), and runs under **docker-compose** (base + per-env override).