Architecture¶
Two application repositories produce five images and seven services.
Cloudflare Tunnel (outbound only)
│
game. ────┬── /<game>/assets/ ─┼──> fe-embeds (nginx, static)
└── everything else ─┼──> games-api (:4100)
bo. ────────────── Access ───┼──> fe-backoffice (nginx, SPA)
bo-api. ────────────────────── ┼──> bo-api (:4101)
logs. ────────────── Access ───┴──> logs-ui (Dozzle)
worker no HTTP surface, no hostname
postgres 127.0.0.1 only, reachable over Tailscale
redis 127.0.0.1 only, reachable over Tailscale
The embed host¶
The single most important structural fact.
- games-api serves the document. It fetches the built
index.htmlfromEMBED_STATIC_URL(the static image, over the compose network) and adds what only a database-connected process can:frame-ancestorsbuilt fromcore.installation.allowed_origins, the same list as anhg-allowed-originsmeta tag, and the operator's boot theme. - fe-embeds serves only the assets, under
/<game>/assets/. Both games ship in that one image — two images behind one host cannot both own/assets/.
The tunnel expresses this as two ingress rules, and order matters:
game.evocase-iframe.io path=^/[^/]+/assets/ -> http://fe-embeds:8080
game.evocase-iframe.io path=— -> http://games-api:4100
(catch-all) -> http_status:404
fe-embeds must never get a hostname of its own
With one, https://<host>/lootbox/?lt=… serves the document without the
frame-ancestors header and the whole arrangement is bypassed. The bridge
then falls back to trusting whoever embedded it.
Locally the same split is done by the edge service in
docker-compose.override.yml. Keep the two in step — otherwise what works on a
laptop is not what is deployed.
The backend image¶
One image per app, selected at build time with --build-arg APP_TARGET=<dir>,
where the selector is the bare directory name under apps/.
Three properties that shape the compose file:
- No
CMD.ENTRYPOINTis["dumb-init","node"]and the deployment names the service. The Doppler overlay replaces the entrypoint, becausedoppler runhas to come beforenode, not after it. - The Doppler CLI is installed in the image. Configuration arrives at process start, so no secret is ever rendered onto the VM's disk — and a backend config change needs only a restart.
curlis purged from the final stage. Healthchecks go through Node.
prisma and tsx are runtime dependencies on purpose, so migrations and the
seed run from the same image the service runs from.
libs/ is shared by all four apps, so a change there affects every one of them.
The frontend images¶
Two Dockerfile stages out of one pnpm workspace: backoffice and embeds.
VITE_API_URL and VITE_BO_API_URL are compiled into the bundle. A change
to them needs a rebuild, not a restart — and it fails silently if you only
restart. That asymmetry is why the frontend Doppler webhook fires a build and
the backend one fires a deploy.