System architecture
1. Topology
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.
2. Request path
Two details that are load-bearing:
- nginx sets
X-Forwarded-Proto: https. TLS ended at the Cloudflare edge, so without this the app would believe it is serving plain HTTP and refuse to emitSecurecookies. - nginx emits no CORS headers at all. Elysia owns them (
src/libs/utils/cors.util.ts). DuplicatedAccess-Control-Allow-Originheaders make every credentialed request fail outright — this is written intodeploy/nginx.confas a comment precisely so nobody re-adds them.
3. Containers
| Service | Image | Why it is pinned / configured this way |
|---|---|---|
postgres | postgres:16-alpine | shared_buffers=64MB, max_connections=20 — the box is small; defaults starve MinIO and Bun |
redis | redis:7-alpine | maxmemory 96mb, allkeys-lru. Cache and rate-limit store: evicting beats OOM-killing Postgres |
minio | RELEASE.2025-04-22T22-12-26Z | Pinned, never :latest |
minio-init | mc RELEASE.2025-04-16T18-13-26Z | One-shot bucket create + anonymous set none |
api | ghcr.io/…/smart-work-permit-api:${IMAGE_TAG} | Tag is the commit SHA. env_file: .env |
nginx | nginx:1.27-alpine | client_max_body_size 25M so a >10 MB upload is rejected by the app with its own errorCode, not by nginx as bare HTML |
cloudflared | cloudflare/cloudflared:2026.8.2 | Pinned: latest means a connector upgrade lands on whatever day you next run up -d |
Startup ordering
api waits on postgres, redis and minio being healthy. nginx waits on api being healthy. cloudflared deliberately has no depends_on: naming nginx would transitively demand an api image that does not exist in GHCR before CI has built one, making an infrastructure-only first bring-up impossible. The tunnel retries its origins on its own, so ordering buys nothing — without it, cloudflared serves 502 for the few seconds before nginx is up, which it would do anyway.
The api healthcheck hits GET / — the bare health route outside /api. Everything under /api/v1 is guarded and answers 401, which would read as a permanently unhealthy container.
The scheduled work runs in-process
PermitExpiryCronPlugin mounts @elysiajs/cron at a one-minute pattern and is deliberately wired into src/app.ts — the real listen() entrypoint — and never into app.module.ts or app.plugin.ts, which the service and controller specs pull in with a bare new Elysia().use(...). A cron firing mid-test-run against the test database is exactly the hidden side effect this repo avoids elsewhere.
One tick drives two independent sweeps — permit expiry (plus the Fire Watch grace alert) and the gas-reading overdue/escalation sweep — each in its own try/catch. protect: true blocks a new trigger while one is still running, so a backlog after downtime delays work rather than overlapping it. One api replica is therefore load-bearing twice over: once for the migration race, once because a second replica would double every sweep.
4. API internals
ElysiaJS, organised as modules under src/modules/, each split into commands/ (writes) and queries/ (reads), with a lib/ for shared models. Round 4 (2026-09-11) deleted the area module and added pin, so the count is still fourteen:
CORS_ORIGIN is a single environment variable driving two things: the @elysiajs/cors origin list and better-auth's trustedOrigins. One edit covers both. It is an explicit comma-separated list — credentialed CORS refuses *, and the code enforces that by only setting credentials: true when the list contains no wildcard.
5. Auth and the cookie boundary
- Session cookie is issued by better-auth. Under
NODE_ENV=productionit gets the__Secure-prefix — never hardcode the cookie name in a client. COOKIE_DOMAIN=.e-safework.comturns oncrossSubDomainCookies, which is what lets a cookie set byapi.e-safework.combe sent byapp.e-safework.comandsafety.e-safework.com.- The consequence: a browser on
esw-safety.pages.devwill reject a cookie scoped to.e-safework.comoutright — different registrable domain. See Current state & blockers.
Three cookie layouts, and what each trades
scripts/check-cookie-domain.sh in the API repo asserts all three. It exists because a wrong Domain or SameSite is invisible until production.
| Layout | Cookie | Sessions |
|---|---|---|
COOKIE_DOMAIN set | Domain=.e-safework.com, SameSite=Lax | One, shared by every subdomain |
COOKIE_DOMAIN unset + COOKIE_SAMESITE=Lax | host-only per API hostname, SameSite=Lax | One per app |
COOKIE_DOMAIN unset, nothing else | host-only, SameSite=None | one per API hostname, cross-site |
Sharing is a trade, not a free win. One cookie name on one domain is one session, so with the apex layout, signing into safety.e-safework.com signs you out of app.e-safework.com in the same browser profile. To hold a contractor session and an officer session at once, give each SPA its own API hostname — api. and api-safety., both routed to the same container — and leave COOKIE_DOMAIN unset so each issues a host-only cookie. The browser keys its cookie jar by host, so the isolation costs no application code.
Host-only does not mean cross-site. app.e-safework.com and api.e-safework.com share the registrable domain e-safework.com, so requests between them are same-site and SameSite=Lax is sent on them — no third-party-cookie policy applies. SameSite=None is only needed when an SPA sits on a genuinely different registrable domain, which is exactly the *.pages.dev case above. Conflating the two is what made the third layout look like the only alternative to the first.
COOKIE_SAMESITE is validated at boot: a value outside Lax / None / Strict throws rather than falling back silently, because everything in this area fails invisibly until production.
Public signup is disabled (USER_DISABLE_SIGNUP). Accounts are created only through a safety_officer-gated provisioning endpoint, and are deactivated, never deleted — permit.createdById is a plain scalar and the audit log denormalizes the actor, so removing the row would leave both pointing at an id that resolves to nothing.
6. Environment invariants
TZ=UTCon every container. 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/dailyEndareTIMEcolumns with no date part, read back anchored to1970-01-01T00:00:00Z. A client must convert them to local time for display; rendering them as a UTC wall clock shifts every permit by the deployment's offset with nothing failing. CORS_ORIGINnames exact origins — scheme included, no trailing slash.- camelCase both directions. No case conversion anywhere.
- One
apireplica.prisma migrate deployruns on container start; one container means no migration race.