Skip to content

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 emit Secure cookies.
  • nginx emits no CORS headers at all. Elysia owns them (src/libs/utils/cors.util.ts). Duplicated Access-Control-Allow-Origin headers make every credentialed request fail outright — this is written into deploy/nginx.conf as a comment precisely so nobody re-adds them.

3. Containers

ServiceImageWhy it is pinned / configured this way
postgrespostgres:16-alpineshared_buffers=64MB, max_connections=20 — the box is small; defaults starve MinIO and Bun
redisredis:7-alpinemaxmemory 96mb, allkeys-lru. Cache and rate-limit store: evicting beats OOM-killing Postgres
minioRELEASE.2025-04-22T22-12-26ZPinned, never :latest
minio-initmc RELEASE.2025-04-16T18-13-26ZOne-shot bucket create + anonymous set none
apighcr.io/…/smart-work-permit-api:${IMAGE_TAG}Tag is the commit SHA. env_file: .env
nginxnginx:1.27-alpineclient_max_body_size 25M so a >10 MB upload is rejected by the app with its own errorCode, not by nginx as bare HTML
cloudflaredcloudflare/cloudflared:2026.8.2Pinned: 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.

  • Session cookie is issued by better-auth. Under NODE_ENV=production it gets the __Secure- prefix — never hardcode the cookie name in a client.
  • COOKIE_DOMAIN=.e-safework.com turns on crossSubDomainCookies, which is what lets a cookie set by api.e-safework.com be sent by app.e-safework.com and safety.e-safework.com.
  • The consequence: a browser on esw-safety.pages.dev will reject a cookie scoped to .e-safework.com outright — different registrable domain. See Current state & blockers.

scripts/check-cookie-domain.sh in the API repo asserts all three. It exists because a wrong Domain or SameSite is invisible until production.

LayoutCookieSessions
COOKIE_DOMAIN setDomain=.e-safework.com, SameSite=LaxOne, shared by every subdomain
COOKIE_DOMAIN unset + COOKIE_SAMESITE=Laxhost-only per API hostname, SameSite=LaxOne per app
COOKIE_DOMAIN unset, nothing elsehost-only, SameSite=Noneone 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 deletedpermit.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=UTC on 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 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 are TIME columns with no date part, read back anchored to 1970-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_ORIGIN names exact origins — scheme included, no trailing slash.
  • camelCase both directions. No case conversion anywhere.
  • One api replica. prisma migrate deploy runs on container start; one container means no migration race.