--- 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= -t eatme . docker run -d -p 4000:4000 -v eatme-data:/app/data \ -e GOOGLE_CLIENT_ID= -e ADMIN_EMAILS= \ -e JWT_SECRET= -e CORS_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= -t eatme:latest . docker save eatme:latest | gzip | ssh "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 :~/eatme/compose.yaml ssh "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/` for whichever host runs nginx, proxying `https://` to `http://:`. 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)