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>
This commit is contained in:
109
docs/architecture/deployment.md
Normal file
109
docs/architecture/deployment.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user