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>
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. |
|
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.json → tsc -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:
frontend-build—npm ci+npm run buildinfrontend/. TakesVITE_GOOGLE_CLIENT_IDas a build arg.backend-build—npm ci+npm run buildinbackend/, producing the esbuild bundle.runtime— freshnpm ci --omit=devforbackend/package.jsonon the same base image asbackend-build(sobetter-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 asdist/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 |