4 Commits

Author SHA1 Message Date
julian aa546235fb docs(wiki): container-deployment — relative /api + Caddy proxy web-access section
Build & push images / images (push) Successful in 2m40s
CI / check (push) Successful in 34s
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 19:15:44 +02:00
julian c637b2783c feat(deploy): Caddy reverse proxy — clean port-80 URL, server internal
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
2026-06-23 19:15:23 +02:00
julian 77b2acb1ca fix(docker): SPA must use same-origin API base in the server image (CORS)
apps/web/.env.production sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
desktop build (which loads from tauri://localhost and needs an absolute backend
origin). But Vite auto-loads .env.production for ANY `vite build`, so the server
image baked 127.0.0.1:3000 into the browser bundle — loading the UI from a real
host (e.g. http://parksystems.msai.al) then made the browser call 127.0.0.1:3000
cross-origin and fail the Same-Origin Policy on /api/auth/login.

Fix: the server Dockerfile writes apps/web/.env.production.local with an empty
VITE_API_BASE before the web build (.local has higher Vite precedence), so the SPA
served by Fastify stays relative/same-origin (/api/...). The desktop build is
unaffected (it doesn't use this Dockerfile). Verified: 127.0.0.1:3000 no longer in
the built bundle; /api/auth/login is relative.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:58:53 +02:00
julian 10923164ad fix(compose): pass COOKIE_SECURE, WS_ALLOWED_ORIGINS, EVENT_SIGNING_KEY, VISION_ENABLED
The base compose only forwarded DATABASE_URL/VISION_URL/JWT_SECRET, so a booth deploy
was missing the vars that actually make it usable on the plain-HTTP LAN:
- COOKIE_SECURE (default 0) — without it auth cookies are HTTPS-only and operators
  CANNOT log in over http. The #1 booth-deploy footgun.
- WS_ALLOWED_ORIGINS — the live-feed WS rejects the browser Origin without it.
- EVENT_SIGNING_KEY — dedicated ledger key (falls back to JWT_SECRET if empty).
- VISION_ENABLED=1 — the server's ANPR master switch.
All driven from .env; verified via `docker compose config` that the seven vars resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-23 18:37:52 +02:00
5 changed files with 80 additions and 4 deletions
+13
View File
@@ -0,0 +1,13 @@
# Booth reverse proxy. `:80` matches ANY hostname/IP, so the booth is reachable as
# http://<booth-ip>/, http://localhost/, or http://parksystems.msai.al/ (the name pointed
# at the booth's IP via hosts/DNS on-site) — with no domain baked into any image. The SPA
# uses a relative /api base, so everything (HTTP + the /api/ws WebSocket, which Caddy
# upgrades automatically) just flows through to the server container.
#
# TLS later: replace `:80` with the real hostname (e.g. `parksystems.msai.al`), uncomment
# Caddy's :443 in docker-compose.prod.yml, and Caddy auto-provisions HTTPS. For a private
# CA / internal cert, use `tls /path/cert.pem /path/key.pem`.
:80 {
encode gzip
reverse_proxy server:3000
}
+6
View File
@@ -27,6 +27,12 @@ ENV CI=true
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --offline
# Force the SPA to use a SAME-ORIGIN (relative) API base for THIS image. Vite auto-loads
# apps/web/.env.production, which sets VITE_API_BASE=http://127.0.0.1:3000 for the TAURI
# DESKTOP build — but here Fastify serves the SPA same-origin, so an absolute base would
# make the browser hit 127.0.0.1:3000 cross-origin and fail CORS. `.env.production.local`
# has higher precedence than `.env.production`, so this empties it for the server image only.
RUN echo 'VITE_API_BASE=' > apps/web/.env.production.local
# Builds shared/db/devices, the server dist, AND the web SPA dist (apps/web/dist).
RUN pnpm turbo run build --filter=@parking/server --filter=@parking/web
# `pnpm deploy` produces a SELF-CONTAINED prod bundle for the server in /deploy: a hoisted
+35 -4
View File
@@ -1,14 +1,41 @@
# PROD override: pull pinned registry images (no local build), restart always, real
# recognizer, and keep vision INTERNAL (only the server port is published). Use with the
# base file and pin TAG to the branch/SHA you deploy:
# recognizer, and a CADDY reverse proxy in front so operators reach the booth on a clean
# port-80 URL (no :3000) — and a path to real TLS later. Server + vision stay INTERNAL
# (only Caddy publishes a port). Use with the base file and pin TAG to the branch you deploy:
# REGISTRY=git.infra.msai.al/mca/parking_solution TAG=main \
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# See wiki/decisions/container-deployment.md.
services:
server:
# Reverse proxy: :80 → server:3000 (WebSocket /api/ws upgrades pass through natively).
# Caddy is a single static binary with a one-line proxy config; swapping http:// for the
# site's real hostname later enables automatic HTTPS. The booth is reached at
# http://<name-or-ip>/ (the name set via hosts/DNS on-site — NOT baked into any image).
proxy:
image: caddy:2-alpine
restart: always
ports:
- "3000:3000"
- "80:80"
# - "443:443" # uncomment when moving to TLS (and set a real hostname in Caddyfile)
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- server
networks:
- parking
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
server:
restart: always
# No published port — only the proxy reaches the server, over the private network.
expose:
- "3000"
logging:
driver: json-file
options:
@@ -26,3 +53,7 @@ services:
options:
max-size: "10m"
max-file: "3"
volumes:
caddy-data:
caddy-config:
+11
View File
@@ -15,8 +15,19 @@ services:
DATABASE_URL: /data/parking.sqlite
# Reach the vision service over the private compose network by service name.
VISION_URL: http://vision:8089
VISION_ENABLED: ${VISION_ENABLED:-1}
# JWT signing secret MUST be provided at deploy (no insecure default — see auth.ts).
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in the env/.env}
# Dedicated ledger-signing key. Falls back to JWT_SECRET (with a warning) if empty;
# set a distinct one in prod. See apps/server/.env.example + local-jwt-auth.
EVENT_SIGNING_KEY: ${EVENT_SIGNING_KEY:-}
# CRITICAL on the plain-HTTP booth LAN: cookies are Secure (HTTPS-only) by DEFAULT,
# so without COOKIE_SECURE=0 the auth cookie is never sent over http and operators
# CANNOT LOG IN. Leave unset only behind TLS. See disk-os-hardening "deploy-time runbook".
COOKIE_SECURE: ${COOKIE_SECURE:-0}
# The booth WS live feed checks the browser Origin — must list the address operators
# actually hit (e.g. http://<booth-ip>:3000), or the live feed is rejected.
WS_ALLOWED_ORIGINS: ${WS_ALLOWED_ORIGINS:-}
volumes:
- parking-data:/data
depends_on:
+15
View File
@@ -72,6 +72,21 @@ The **desktop** app stays on its own tag-only `release.yml` (Tauri installers),
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`/