10 — Deployment Overview (shared)
Status: normative. Copy this file into all four repos at docs/main/dev-handoff/10-DEPLOYMENT-OVERVIEW.md, alongside 00-SHARED-CONTEXT.md. Each repo also gets exactly one of 11 / 12 / 13 / 14.
This file describes where things run and how they reach each other. The per-repo files describe what you type.
1. Topology
GitHub Cloudflare Single VM (Docker Compose)
────── ────────── ─────────────────────────
landing ──CI──> Pages ────> <domain> (apex) (static, no API)
contractor-web ──CI──> Pages ────> app.<domain> ─────┐
safety-web ──CI──> Pages ────> safety.<domain> ──┤ credentialed
│ XHR (cookie)
api ──CI──> GHCR image ──SSH deploy──────────────────┐ │
▼ ▼
api.<domain> ──> cloudflared ──> nginx ──> api:3000
storage.<domain> ──> cloudflared ──────────> minio:9000
api ──> postgres:5432
api ──> redis:6379
api ──> minio:9000Nothing on the VM listens on a public port. cloudflared dials outbound to Cloudflare's edge; the VM firewall allows SSH only. There is no inbound 80/443 and no TLS certificate to manage on the box — Cloudflare terminates TLS at the edge.
2. Hostnames
| Host | Serves | Backed by |
|---|---|---|
<domain> (apex) + www. | Marketing landing site | Cloudflare Pages project esw-landing |
app.<domain> | Contractor web app | Cloudflare Pages project esw-contractor |
safety.<domain> | Safety Officer + Inspector web app | Cloudflare Pages project esw-safety |
api.<domain> | Elysia API (/api/v1/...) | Tunnel → nginx → api |
storage.<domain> | MinIO S3 data path (presigned URLs) | Tunnel → minio:9000 |
The four application hosts are all subdomains of one apex domain. This is a hard requirement, not a preference.
The API authenticates with a better-auth session cookie, not a bearer token (04-api-contract.md §2). Same-site subdomains let the cookie be issued with Domain=.<domain>; SameSite=Lax; Secure. If the frontends instead lived on *.pages.dev, the cookie would be cross-site and require SameSite=None, which Safari ITP and any browser with third-party cookies disabled will drop — inspectors on iPhones would silently fail to stay logged in. Do not deploy the frontends on pages.dev hostnames for anything but preview builds.
The landing site is the deliberate exception: it sits on the apex, not under it. The Domain=.<domain> argument does not bind it, because the landing issues and reads no cookie — it is fully static and first-party, and its build gate fails on any off-origin request. That session cookie is still sent to the apex, which is exactly why nothing dynamic may ever be added there. See 14-landing-deployment.md §2.
The MinIO console (port 9001) is deliberately not exposed. Reach it over an SSH tunnel.
3. Component inventory
| Component | Version / image | Where |
|---|---|---|
| Landing site | Vue 3 + Vite static bundle | Cloudflare Pages |
| Contractor web | Vue 3 + Vite static bundle | Cloudflare Pages |
| Safety/Inspector web | Vue 3 + Vite static bundle | Cloudflare Pages |
| API | oven/bun:1.3.13-slim, Elysia 1.4.x | VM container api |
| Database | postgres:16-alpine | VM container postgres, volume pgdata |
| Cache/queue | redis:7-alpine | VM container redis, volume redisdata |
| Object storage | minio/minio (pinned tag) | VM container minio, volume miniodata |
| Reverse proxy | nginx:1.27-alpine | VM container nginx |
| Ingress | cloudflare/cloudflared | VM container cloudflared |
| Registry | GitHub Container Registry (GHCR) | ghcr.io/<owner>/smart-work-permit-api |
4. Deploy order
First-time bring-up, and after any change that alters the API contract:
- Backend — migrations run, image healthy,
GET https://api.<domain>/answers. - Regenerate the contract —
./scripts/dump-openapi.sh, commitdocs/openapi.json. - Copy the contract into both frontends —
docs/api/openapi.jsonin each. - Frontends — either order; they are independent of each other.
scripts/check-contract-sync.mjs runs in both frontend CI pipelines and fails the build on drift. That is the point: a shape change should break the pipeline, not the screen.
The landing site is outside this order entirely — it consumes no API and can deploy at any time (14-landing-deployment.md).
5. Environment invariants
These apply everywhere and are the source of most first-deploy failures:
TZ=UTCon every container. All timestamps are stored UTC (00-SHARED-CONTEXT.md). The 30-minute Fire Watch, the 2-hour gas re-test interval with its 30-minute grace, the permit expiry sweep and the 30-day certificate warning are all computed server-side. A VM defaulting toAsia/Bangkokshifts every one of them by 7 hours. Frontends render inAsia/Bangkok; the server never does.- A permit's
dailyStart/dailyEndcarry no date. They are PostgresTIMEcolumns, read back anchored to1970-01-01T00:00:00Z, and the frontends must convert them to local time. This is the one field pair a wrong render leaves plausible rather than broken. CORS_ORIGINis an explicit list. Credentialed CORS refuses*. It must name the exact originshttps://app.<domain>andhttps://safety.<domain>— scheme included, no trailing slash.- CORS headers are emitted by Elysia only. Do not add
add_header Access-Control-Allow-Originin nginx. Duplicated headers make credentialed requests fail outright. NODE_ENV=productionis what flips better-auth to__Secure--prefixed cookies. Never hardcode the cookie name in a client.- camelCase both directions. No humps conversion anywhere (
04-api-contract.md§6.3).
6. Secrets
| Repo | Secret | Notes |
|---|---|---|
| api | VM_HOST | Public IP or SSH hostname of the VM |
| api | VM_SSH_KEY | Private half of the CI deploy keypair |
| api | GHCR_TOKEN | PAT with read:packages, used by the VM to pull |
| contractor-web | CF_API_TOKEN, CF_ACCOUNT_ID | Token scope: Cloudflare Pages — Edit |
| safety-web | CF_API_TOKEN, CF_ACCOUNT_ID | Same values, added per repo |
| landing | CF_API_TOKEN, CF_ACCOUNT_ID | Same values again. No VITE_* — the landing has none |
The VM's /opt/swp/.env is created by hand, once, and is never in git and never written by CI. CI only sets IMAGE_TAG at deploy time.
7. What is deliberately not here
- No staging environment. If you add one, give it its own apex subdomain (
api-stg.,app-stg.) and its ownCORS_ORIGIN— do not point PR previews at production. - No horizontal scaling of
api.prisma migrate deployruns on container start; one container means no migration race. See11-backend-deployment.md§9 before adding a second replica. - No push notifications. Both frontends poll
GET /notifications.
8. Cross-references
| Question | File |
|---|---|
| What the product does, roles, status machine | 00-SHARED-CONTEXT.md |
| Exact endpoints, envelopes, error codes | 04-api-contract.md |
| Backend VM + infrastructure deploy | 11-backend-deployment.md |
| Contractor web deploy | 12-contractor-web-deployment.md |
| Safety/Inspector web deploy | 13-safety-inspector-web-deployment.md |
| Landing site deploy | 14-landing-deployment.md |