Skip to content

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.html from EMBED_STATIC_URL (the static image, over the compose network) and adds what only a database-connected process can: frame-ancestors built from core.installation.allowed_origins, the same list as an hg-allowed-origins meta 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. ENTRYPOINT is ["dumb-init","node"] and the deployment names the service. The Doppler overlay replaces the entrypoint, because doppler run has to come before node, 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.
  • curl is 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.