---
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)
