The USB ESC/POS printer is the host's /dev/usb/lpN (usblp char device, major 180),
but the container has its own /dev — `docker exec server ls /dev/usb` → "No such
file or directory", so probeUsb's open() ENOENTs and the printer is always offline
regardless of the path set in setup. Containerization isolates host hardware (same
root cause as the network fix); USB needs explicit passthrough:
- volumes: /dev/usb:/dev/usb → the lpN NODES appear inside the container
- device_cgroup_rules: 'c 180:* rmw' → permit the usblp char major (180), and the
`:*` minor wildcard survives lp0/lp1/lp2 renumbering across replug/boot-order.
Binding the /dev/usb DIR (not a single `devices:` node) is what survives renumber.
Merge verified: parking-data ledger volume preserved (lists append), host net intact,
config valid. Booth prereq: `usblp` loaded at boot + printer attached before start,
else /dev/usb is absent.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
network_mode: host + sysctls: net.ipv4.ping_group_range fails at container create:
"sysctl not allowed in host network namespace" — runc refuses a per-netns sysctl
when there's no separate netns. Remove it; under host net the server uses the HOST's
ping_group_range (set on the booth via /etc/sysctl.d). Fixes the park-buzi-server-1
start failure introduced by c87dcb2.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
In prod the containerized server sat on the Docker bridge (172.18.0.x) and could
only see eth0 — so the setup backend-IP picker (net.ts networkInterfaces) showed
just the Docker IP, the server couldn't reach the relay or fetch Hikvision ISAPI
snapshots, and push devices (readers/cameras) couldn't reach it. The server is the
ONLY container doing device I/O, so put it on the HOST network namespace.
- docker-compose.prod.yml: server + proxy → network_mode: host (server detaches the
base `parking` network via `networks: !reset []`). server VISION_URL=127.0.0.1:8089.
vision stays BRIDGED (it never touches a device — the server hands it JPEG bytes)
but publishes 8089 on 127.0.0.1 only, so the host-net server reaches it over
loopback while the ANPR service stays off the LAN.
- docker-compose.yml: VISION_URL is now ${VISION_URL:-http://vision:8089} so dev keeps
compose-DNS service-name routing; prod overrides to loopback.
- Caddyfile: reverse_proxy 127.0.0.1:3000 (was server:3000 — service DNS doesn't
resolve on host net). Dev doesn't use Caddy, so unaffected.
Merge validated for both envs (booth.sh config, exit 0). Host-net side effect: the
container ping_group_range sysctl is a no-op — the HOST must set it for reader ICMP
liveness (see appliance-provisioning).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Two genuinely-offline QR readers showed GREEN: the adapter's healthCheck was
hardcoded to { ready, "stub" } and never probed. These are PUSH devices (scan →
GET our backend, resolve by serial) with NO TCP port, so a connect probe has
nothing to hit — the stub "solved" that by lying. False-healthy is the worst
failure for a status bar.
- Optional reader IP field (monitor-ONLY; scans still resolve by serial,
operation unchanged).
- Unprivileged ICMP ping (drivers/icmp.ts): shells /bin/ping -c1, exit-0 = reply.
No native dep, no CAP_NET_RAW. docker-compose.prod.yml sets
net.ipv4.ping_group_range so it works for the non-root container user.
- healthCheck: replies → ready, no reply → offline, NO IP → degraded
("set IP to monitor") — never a false green.
Verified on hardware: readers (10.0.10.7/.8) answer ICMP on the device VLAN;
UI Test connection → "● ready — ping 10.0.10.7". Tests: reader.test.ts (4).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
Operators/admins reach the booth at http://<name-or-ip>/ (no :3000). Adds a caddy:2-alpine
proxy to the prod override that reverse-proxies :80 → server:3000 (the /api/ws WebSocket
upgrades pass through natively); the server is now `expose: 3000` (internal, no published
port), vision stays internal. The Caddyfile binds `:80` so it matches ANY hostname/IP —
works for the booth IP, localhost, AND parksystems.msai.al (pointed at the booth via
hosts/DNS on-site; no domain baked into any image). TLS later = swap `:80` for the real
hostname + uncomment :443 → Caddy auto-provisions HTTPS.
Pairs with the relative-/api SPA fix (77b2acb): together verified end-to-end locally —
through Caddy on :80 with Host: parksystems.msai.al, GET / serves the SPA, assets/health
200, and POST /api/auth/login reaches the server (real 401, no CORS/connection error).
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
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