Skip to content

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:9000

Nothing 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

HostServesBacked by
<domain> (apex) + www.Marketing landing siteCloudflare Pages project esw-landing
app.<domain>Contractor web appCloudflare Pages project esw-contractor
safety.<domain>Safety Officer + Inspector web appCloudflare 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

ComponentVersion / imageWhere
Landing siteVue 3 + Vite static bundleCloudflare Pages
Contractor webVue 3 + Vite static bundleCloudflare Pages
Safety/Inspector webVue 3 + Vite static bundleCloudflare Pages
APIoven/bun:1.3.13-slim, Elysia 1.4.xVM container api
Databasepostgres:16-alpineVM container postgres, volume pgdata
Cache/queueredis:7-alpineVM container redis, volume redisdata
Object storageminio/minio (pinned tag)VM container minio, volume miniodata
Reverse proxynginx:1.27-alpineVM container nginx
Ingresscloudflare/cloudflaredVM container cloudflared
RegistryGitHub 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:

  1. Backend — migrations run, image healthy, GET https://api.<domain>/ answers.
  2. Regenerate the contract./scripts/dump-openapi.sh, commit docs/openapi.json.
  3. Copy the contract into both frontendsdocs/api/openapi.json in each.
  4. 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=UTC on 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 to Asia/Bangkok shifts every one of them by 7 hours. Frontends render in Asia/Bangkok; the server never does.
  • A permit's dailyStart/dailyEnd carry no date. They are Postgres TIME columns, read back anchored to 1970-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_ORIGIN is an explicit list. Credentialed CORS refuses *. It must name the exact origins https://app.<domain> and https://safety.<domain> — scheme included, no trailing slash.
  • CORS headers are emitted by Elysia only. Do not add add_header Access-Control-Allow-Origin in nginx. Duplicated headers make credentialed requests fail outright.
  • NODE_ENV=production is 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

RepoSecretNotes
apiVM_HOSTPublic IP or SSH hostname of the VM
apiVM_SSH_KEYPrivate half of the CI deploy keypair
apiGHCR_TOKENPAT with read:packages, used by the VM to pull
contractor-webCF_API_TOKEN, CF_ACCOUNT_IDToken scope: Cloudflare Pages — Edit
safety-webCF_API_TOKEN, CF_ACCOUNT_IDSame values, added per repo
landingCF_API_TOKEN, CF_ACCOUNT_IDSame 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 own CORS_ORIGIN — do not point PR previews at production.
  • No horizontal scaling of api. prisma migrate deploy runs on container start; one container means no migration race. See 11-backend-deployment.md §9 before adding a second replica.
  • No push notifications. Both frontends poll GET /notifications.

8. Cross-references

QuestionFile
What the product does, roles, status machine00-SHARED-CONTEXT.md
Exact endpoints, envelopes, error codes04-api-contract.md
Backend VM + infrastructure deploy11-backend-deployment.md
Contractor web deploy12-contractor-web-deployment.md
Safety/Inspector web deploy13-safety-inspector-web-deployment.md
Landing site deploy14-landing-deployment.md