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>
110 lines
4.5 KiB
Markdown
110 lines
4.5 KiB
Markdown
---
|
|
type: Architecture Overview
|
|
title: Build & deployment
|
|
description: esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host.
|
|
tags: [architecture, deployment, docker]
|
|
timestamp: 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:
|
|
|
|
1. `frontend-build` — `npm ci` + `npm run build` in `frontend/`. Takes
|
|
`VITE_GOOGLE_CLIENT_ID` as a build arg.
|
|
2. `backend-build` — `npm 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.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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 |
|
|
|
|
## Related
|
|
|
|
- [System overview](./overview.md)
|