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
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user