115 lines
7.6 KiB
Markdown
115 lines
7.6 KiB
Markdown
---
|
|
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".
|
|
|
|
## 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).
|