Files
eatme/docs/architecture/deployment.md
Michal Pemcak fffcb73ea4 Initial commit: EatMe attendance tracker
Google-SSO PWA for bistro employee clock-in/out, admin employee
management, and stats with CSV export. Express + SQLite backend,
React + Zustand frontend in the light-mono-tui design language.
Multi-stage Dockerfile, compose.yaml for image-based deploys, nginx
reverse-proxy template, and an OKF documentation bundle in docs/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 18:41:11 +02:00

4.5 KiB

type, title, description, tags, timestamp
type title description tags timestamp
Architecture Overview Build & deployment esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host.
architecture
deployment
docker
2026-08-12T12:00:00Z

Build & deployment

Backend build

backend/package.json's build script runs tsc --noEmit (typecheck only) then node esbuild.config.js, which bundles src/index.ts into a single minified dist/index.js (~1.4MB, with sourcemap). better-sqlite3 is marked external in esbuild.config.js because it ships a native .node binding that can't be bundled — it stays a real node_modules dependency at runtime.

Frontend build

Standard Vite build (frontend/package.jsontsc -b && vite build), producing an optimized static dist/ with a service worker (vite-plugin-pwa, frontend/vite.config.ts). VITE_* env vars (just VITE_GOOGLE_CLIENT_ID) are inlined at build time — they cannot be changed at container runtime, only at image-build time.

Docker image

The root Dockerfile is a three-stage build:

  1. frontend-buildnpm ci + npm run build in frontend/. Takes VITE_GOOGLE_CLIENT_ID as a build arg.
  2. backend-buildnpm ci + npm run build in backend/, producing the esbuild bundle.
  3. runtime — fresh npm ci --omit=dev for backend/package.json on the same base image as backend-build (so better-sqlite3's native binding is compiled for the environment it'll actually run in, rather than copied from a different stage). Then copies in the backend bundle (dist/index.js) and the frontend build output as dist/public/.

At runtime, backend/src/index.ts checks whether dist/public exists next to itself and, if so, serves it with express.static plus a SPA fallback route (app.get(/^(?!\/api\/).*/, ...)) — that's what makes the single image serve both API and UI on one origin. In local dev this directory doesn't exist, so the branch is skipped and the frontend's own Vite dev server is used instead.

docker build --build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme .
docker run -d -p 4000:4000 -v eatme-data:/app/data \
  -e GOOGLE_CLIENT_ID=<id> -e ADMIN_EMAILS=<emails> \
  -e JWT_SECRET=<secret> -e CORS_ORIGIN=<origin> -e COOKIE_SECURE=true \
  eatme

The SQLite file lives at DB_PATH (default /app/data/eatme.db in the image) — mount /app/data as a volume or it's lost on container recreate.

Shipping to a host

The image is always built where its target platform matches the deploy host, and shipped as a built artifact — never by copying the repo to the server and building there (keeps source off prod hosts, no build toolchain needed remotely, and the exact artifact tested locally is what runs). If building on an Apple Silicon Mac for an x86_64 server, --platform linux/amd64 is required or the image won't run there at all.

docker build --platform linux/amd64 \
  --build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme:latest .
docker save eatme:latest | gzip | ssh <host> "gunzip | docker load"

Only compose.yaml (root of the repo) and a .env next to it (see .env.example) then need to reach the host — compose.yaml references image: eatme:latest (not build:), so docker compose up -d just starts the already-loaded image. Redeploying a new version is the same build → save → load → docker compose up -d sequence again.

scp compose.yaml <host>:~/eatme/compose.yaml
ssh <host> "cd ~/eatme && docker compose up -d"

TLS termination is handled by an nginx reverse proxy in front of the container (not part of this repo) — a template server block lives at deploy/<domain> for whichever host runs nginx, proxying https://<domain> to http://<deploy-host>:<published-port>. It's a plain file to be moved into /etc/nginx/sites-available/ (and symlinked into sites-enabled/) by whoever has sudo on that host — nothing here writes to system nginx config directly.

Environment variables

Var App Required Notes
GOOGLE_CLIENT_ID backend yes must match frontend's VITE_GOOGLE_CLIENT_ID
ADMIN_EMAILS backend yes comma-separated
JWT_SECRET backend yes session signing key
DB_PATH backend no default ./data/eatme.db
CORS_ORIGIN backend no default http://localhost:5173
COOKIE_SECURE backend no set true behind HTTPS
PORT backend no default 4000
VITE_GOOGLE_CLIENT_ID frontend yes build-time only