Deploying
The whole platform on one host with Docker Compose (Technical specs §8.3, §9.6, §9.10): the platform API, a game server, Nakama for accounts, Postgres, MinIO for the built games, and a reverse proxy that puts everything behind one origin, so the web client, its API calls, its WebSockets and the MCP server share an address and need no cross-origin set-up. Everything lives in deploy/ (its README).
What runs
| Service | Image | What |
|---|---|---|
preflight | five-on-it-node (built) | Checks the settings first; nothing else starts on a placeholder secret. |
proxy | five-on-it-proxy (built: Caddy and the web client) | The one origin: / the web client, /api/* the API (prefix stripped), /game and /rooms/* the game server's WebSockets, /mcp the MCP server, /oauth/* and /.well-known/oauth-* (OAuth). The only published service. |
api | five-on-it-node | apps/api on Nakama (FOI_AUTH=nakama), Postgres (FOI_STORE=postgres) and MinIO (FOI_ARTIFACTS=s3; the bucket is created at start). |
game | five-on-it-node | apps/server with rooms on (FOI_ROOM_SECRET) and its default room, which /game joins. |
postgres | postgres:17.11-alpine | One server, two databases: the platform's (foi) and Nakama's (nakama). |
nakama | five-on-it-nakama (built: Nakama 3.41 and the module) | Accounts, sessions, friends, the players' storage and chat channels; migrates its database at start. The API signs players in through it. Its runtime module (apps/nakama) moderates a public room's channel and keeps the party's and direct messages (below). |
minio | pgsty/minio:RELEASE.2026-08-04T00-00-00Z | The built games, by hash. MinIO stopped publishing its own images in 2025; this is the community build of the same server. |
worker | five-on-it-worker (built: the node image + Blender) | apps/worker, the level generator, on a network with no way out, 2 CPUs and 4 GB. |
mcp | five-on-it-node | apps/mcp, the MCP server for Claude Code, on port 8091. |
creator | five-on-it-node | Under the creator profile: apps/creator, the runner of the platform's own model, claiming owners' briefs from the API on the private network (FOI_CREATOR, FOI_CREATOR_SECRET, OPENROUTER_API_KEY or ANTHROPIC_API_KEY: Platform); with FOI_CREATOR=off it says so and stays stopped. |
The Node services share one image (deploy/Dockerfile, target node): the repository with its production dependencies, run as it is (Node strips the TypeScript types; there is no build step), one command per service. Nakama's image (target nakama) is heroiclabs/nakama:3.41.0 with the platform's runtime module built into /nakama/data/modules/index.js. The proxy's image carries the web client's production build (npm run -w apps/client build:web). The worker's adds Blender 5.2.2 for Linux x64, pinned to its version and checksum from download.blender.org.
Requirements
- Docker Engine 25 or later with Compose v2.24 or later, on an x86-64 Linux host (or Docker Desktop). The worker's Blender is the Linux x64 build.
- Memory: about 1.5 GB for the whole stack at rest; the game server grows by 100 to 150 MB a room it holds open (0.4 GB with its default room, 0.7 GB with four rooms played at once), and the worker's Blender takes up to its 4 GB limit while it generates. Plan 8 GB for the host.
- CPU: every room of the game server runs on one thread, so the game takes one core however many the host has: four lightly filled rooms (measured on 0.41.1: the default room with two players, three more cities' rooms with one each) took 66 % of a desktop core, the tick's 99th percentile 14.8 ms of the 16.7 ms a tick has at 60 Hz. Opening a city's room builds its navigation first, in a worker thread (about 11 s on a desktop core for
main-riverbend): the room opens when it is ready, and the other rooms go on meanwhile. How many rooms a VPS core holds is for the load test to say (the deployment plan,documents/DEPLOYMENT_PLAN.md, phase 7). The worker takes 2 CPUs while it generates. - 3 GB of disk for the images (worker 2.05 GB, node 0.6 GB, Nakama 0.16 GB, proxy 0.08 GB) and room for the volumes.
- Node 24 on the host for the npm scripts below;
docker compose -f deploy/compose.yaml …works without it.
Settings
cp deploy/.env.example deploy/.envThen replace every change-me… in deploy/.env with a secret of its own (openssl rand -hex 32 makes one; the Postgres password goes into connection URLs, so letters, digits, - and _ only). The preflight service refuses to start the stack while a secret is a placeholder or shorter than 16 characters; FOI_ALLOW_DEV_SECRETS=1 turns the refusal into a loud warning, for trying the stack on your own machine. Real secrets never go in the repository: deploy/.env is ignored by git.
deploy/.env.example says what every setting does, what it takes, its default and which service reads it, in a comment above each, with a sample where the value is not obvious; npm run env:check keeps it in step with compose.yaml (Quality gates). In short:
- the proxy's address and the public URLs (
FOI_SITE_ADDRESS,FOI_HTTP_PORT,FOI_HTTPS_PORT,FOI_PUBLIC_URL,FOI_PUBLIC_WS_URL: The URLs); - the secrets: the room's (shared by the API and the game server), Postgres', Nakama's and MinIO's, with the bucket the built games go in;
- the moderation and the moderators (Moderation, Platform), and the admins (Operations);
- who the platform is for and its Steam (Platform), who runs it (
FOI_LEGAL_OPERATOR,FOI_LEGAL_CONTACT,FOI_LEGAL_COUNTRY: the privacy policy and the terms name them, and the preflight says so while they are empty) and how long the players' telemetry is kept; - the default room's level, the data channel (below), the fleet (below) and creation by the platform's own model (Platform);
- the optional services (
COMPOSE_PROFILES), the monitoring (below) and the images (FOI_IMAGES,FOI_VERSION: the ones built here when empty, a release's in its bundle).
An app run on its own, without the stack, reads its settings from its environment, as the root .env.example lists them.
Up, smoke, down
npm run stack:up # build the images, start every service, wait until each is healthy
npm run stack:smoke # the whole path through the proxy (below)
npm run stack:down # stop; `npm run stack:down -- -v` also drops the volumesstack:up runs docker compose up --detach --build --wait and prints where the stack answers; when the preflight check refuses the settings, it prints why. On a development machine (2026-10-11, 0.41.1, Docker Desktop on WSL 2), a first start that rebuilt the node, proxy and worker images on cached dependencies took 97 s; a rebuild after a change to the sources 58 to 68 s; a start of the built stack on its kept volumes 24 s, most of it the game server loading its default level (about 14 s, its navigation; main-riverbend, the default today, takes about 20 s). A build from nothing adds Blender's 383 MB download to the worker's image.
stack:smoke (deploy/smoke.mjs) waits for the stack, then checks the path end to end, timing each step (the last two the social layer): a guest signs in through the API (a Nakama device account); a creator signs in by e-mail (a Nakama e-mail account); the creator publishes the bundled test-mission-basics game (a draft, its game document, a build stored in MinIO with its level's preview, public publishing kept in Postgres), whose preview and page come through the proxy; the guest rates it and opens a room of it; the guest joins the room with the URL the API signed, through the proxy, and receives the welcome; a player joins the default room on /game; the creator builds a draft on a level spec (test-generated-town's), which the worker generates in Blender (and renders its preview), and a playtest room welcomes them to it; the MCP server lists its tools and the levels for the creator on /mcp; the creator asks the guest to be friends by name and the guest accepts (Nakama's friends); both join the public room, where the creator's line, checked by the platform's moderation (the game server asks the API), reaches the guest, and a direct message reaches the guest's feed; a player picks the city as the main menu does (/game?level=main-riverbend) and is welcomed to it, while a test level's room is refused (4404); the web client's production build serves the Changa font its stylesheet names, a kit and the preview of every level that ships; the creator builds a draft on a region spec without towns (test-tiles-terrain's) and one on a region with a town (test-tiles-town's, the town built by the worker's Blender on the region's hills, its places among the build's), each played in a playtest room. It takes about 70 s, most of it Blender. FOI_SMOKE_URL points it at another origin.
The URLs
| What | Where (with the defaults) |
|---|---|
| The game, in a browser | http://localhost:8088/ (the Games tab signs in a guest) |
| The API | http://localhost:8088/api/health |
| The default room | ws://localhost:8088/game |
| A room | ws://localhost:8088/rooms/<id>?token=<join token> (from the API) |
| The MCP server | http://localhost:8088/mcp |
| Its OAuth sign-in | http://localhost:8088/oauth/authorize (Claude Code opens it) |
| Nakama's console | http://127.0.0.1:7351 (admin, NAKAMA_CONSOLE_PASSWORD) |
| MinIO's console | http://127.0.0.1:9001 (MINIO_ROOT_USER, MINIO_ROOT_PASSWORD) |
The desktop app plays on the stack from its main menu's Play online, with the stack's address typed on that screen (http://<host>:8088; its /api is asked first), or given as FOI_PLATFORM_URL; a release carries one when the repository variable FOI_PLATFORM_URL is set as it is built. FOI_SERVER_URL=ws://<host>:8088/game still goes straight into the default room. The API answers pages of any origin (CORS), the desktop app's from file:// among them, and says what the stack is at /api/info (its release, its protocol, where its cities are played).
On a public host, point a DNS name at it and set FOI_SITE_ADDRESS=play.example.com, FOI_HTTP_PORT=80, FOI_HTTPS_PORT=443, FOI_PUBLIC_URL=https://play.example.com and FOI_PUBLIC_WS_URL=wss://play.example.com; Caddy gets the certificate on its first start and keeps it in the caddy-data volume. The consoles stay on 127.0.0.1: reach them through an SSH tunnel.
On the internet through ngrok
To try the stack from other networks before it has a host of its own, put it behind an ngrok tunnel: ngrok gives it a public HTTPS address and forwards to the proxy's port, so the proxy stays on plain HTTP and Caddy asks for no certificate.
- Real secrets first. The stack is then on the internet: with the placeholders anyone could sign sessions of any account (Nakama's session key) or join tokens of any room (the room secret). Set every secret in
deploy/.env(openssl rand -hex 32) andFOI_ALLOW_DEV_SECRETS=0. A newPOSTGRES_PASSWORDneeds new volumes (npm run stack:down -- -v): Postgres keeps the password it was created with. - The tunnel:
ngrok http 8088(afterngrok config add-authtoken <token>once). The free plan has one fixed domain per account, claimed in ngrok's dashboard and used with--url=<domain>.ngrok-free.app; without it, every start gets another address, which.envmust then follow (the API signs room links with it, and old links stop working). A random address also carries the machine's public IP address. - The settings:
FOI_SITE_ADDRESS=:80,FOI_PUBLIC_URL=https://<domain>,FOI_PUBLIC_WS_URL=wss://<domain>, thennpm run stack:up. - The smoke through the tunnel:
FOI_SMOKE_URL=https://<domain> FOI_PUBLIC_WS_URL=wss://<domain> npm run stack:smoke(76 s on 2026-10-11, against 68 s at home). - Claude Code through the tunnel: see below.
Making a game in Claude Code on the stack
Claude Code makes the level and its missions on the stack through its MCP server, /mcp behind the proxy; ngrok only carries the traffic: the level is generated by the stack's worker, built and stored by its API in Postgres and MinIO, and played on its game server, all on the machine the stack runs on. With <domain> the tunnel's address (xyz.ngrok-free.app):
The MCP server in Claude Code, under a name of its own: the repository's
.mcp.jsonalready namesfive-on-it, the development platform's (npm run dev:platform), not the stack's. The lines are the same in bash and in PowerShell:consoleclaude mcp add --transport http five-on-it-ngrok https://<domain>/mcp claude mcp get five-on-it-ngrok # must report it connectedSigning in. Claude Code signs you in with OAuth, the stack's API being the authorization server: the first tool that needs your account opens
https://<domain>/oauth/authorizein the browser (or sign in ahead:/mcp,five-on-it-ngrok, Authenticate). Sign in with your e-mail and a password of 8 characters or more (the account, Nakama's, is made on its first sign-in), then Allow; the browser goes back to Claude Code, which keeps the tokens and renews them by itself (Creating games has the steps).Or, instead, paste a session as a header: sign in on the stack's API and add the server with its token.
consoleFOI_TOKEN=$(curl -s -X POST https://<domain>/api/auth/login -H 'content-type: application/json' \ -d '{"email": "you@example.com", "password": "a long password"}' | node -p 'JSON.parse(require("fs").readFileSync(0)).token') claude mcp add --transport http five-on-it-ngrok https://<domain>/mcp --header "Authorization: Bearer $FOI_TOKEN"In PowerShell (Windows), where
curlis another command:powershell$FOI_TOKEN = (Invoke-RestMethod -Method Post -Uri "https://<domain>/api/auth/login" -ContentType "application/json" ` -Body (@{ email = "you@example.com"; password = "a long password" } | ConvertTo-Json)).token claude mcp add --transport http five-on-it-ngrok https://<domain>/mcp --header "Authorization: Bearer $FOI_TOKEN"A game. In Claude Code:
/mcp__five-on-it-ngrok__create_game "a heist at the lake's jetty". Claude reads the catalogue and the guides, writes the level (a bundled one, or one generated from a spec: the stack's worker builds it in Blender) and the missions, builds, fixes what the build reports, and answers with a playtest link at the tunnel's address: it plays in any browser (a phone on mobile data too), or in the desktop app's Play online with the tunnel's address typed on its screen. Publishing (draft_publish) lists it on the community's page.Publishing it. Claude publishes only when asked (the
create_gameprompt keeps the game a private draft until then): say "publish it publicly", and it callsdraft_publishwith"visibility": "public", which lists the game on the community's page (?browse); "publish it by link" shares it with whoever has the link instead. A public game's texts are moderated first: an answer of 422 says which text was refused (Moderation).
Good to know:
- The tunnel lives as long as the process that runs it. Start
ngrok http 8088in a terminal of your own, so it outlives whatever started it. - A new address breaks the old one. When ngrok starts on another address, set it in
deploy/.env(FOI_PUBLIC_URL,FOI_PUBLIC_WS_URL), runnpm run stack:up, thenclaude mcp remove five-on-it-ngrokand add it again (and sign in again: the OAuth issuer is the address). ngrok's free fixed domain (--url=<domain>) keeps one address. - How long a sign-in lasts. With OAuth, an access token lasts an hour and Claude Code renews it with its refresh token, itself replaced at every renewal and good for 30 days unused. A pasted session lasts 24 hours (Nakama's,
compose.yaml): when the tools answer 401, sign in again and add the server again with the new token. - Moderation is off on a stack whose
FOI_MODERATIONisoff: a game published publicly is not checked. - On the stack's own machine no tunnel is needed:
http://localhost:8088/mcp(andhttp://localhost:8088/api/auth/loginfor a pasted session) in the same steps. The tunnel is for other devices and other people, each with an account of their own.
The whole path is tested: stack:smoke (step 8, through the tunnel with FOI_SMOKE_URL) makes a game over /mcp and plays its playtest, and its step 8b signs the creator in with OAuth as Claude Code does (the 401 and the metadata, a registration, the sign-in page and Allow, the code exchanged with PKCE, a tool called with the access token, a refresh, a revocation); E2E-453 (tests/e2e/specs/platform/mcp.spec.ts) makes a game on every pull request against the API, the game server and the MCP server, the link played in the browser, and E2E-516 and E2E-517 (tests/e2e/specs/platform/oauth.spec.ts) sign in on the page in the browser.
ngrok's free plan may show a browser a "You are about to visit" page once; after its button, the page's calls to the API and its WebSockets go through (headless Chromium was never shown it). Stopping ngrok takes the stack off the internet; it keeps running on http://localhost:8088, with FOI_PUBLIC_* to set back for local use.
The MCP integration in Claude Code
A creator adds the stack's MCP server to Claude Code once, by its address, and signs in when Claude Code asks: OAuth 2.1, the API being the authorization server, its sign-in page on Nakama's accounts (the specs §9.6):
claude mcp add --transport http five-on-it http://localhost:8088/mcp
claude mcp get five-on-it # connected?How it is served: the MCP server answers a creator's tool without a token with 401 and WWW-Authenticate: Bearer resource_metadata="<FOI_PUBLIC_URL>/.well-known/oauth-protected-resource/mcp"; that metadata (the MCP server's, FOI_AUTH_URL) names the API as the authorization server at FOI_PUBLIC_URL, whose metadata (/.well-known/oauth-authorization-server), registration, sign-in page, token and revocation endpoints (/oauth/*) Caddy routes to the API at the origin's root, unprefixed (deploy/Caddyfile). The access token is an hour's session of the account, signed with Nakama's session key in Nakama's claims, so every route of the API takes it; the API keeps the clients, the codes, the grants and the refresh tokens (by their hashes) in Postgres. A session pasted as a header still works (--header "Authorization: Bearer <token>", the token from POST /api/auth/login).
On a public host the URL is https://play.example.com/mcp, and FOI_PUBLIC_URL must be https: the sign-in page takes passwords. See Creating games for the loop Claude follows.
Nakama's runtime module
The stack's Nakama loads one runtime module, apps/nakama (its README): a hook before every chat message a client sends on Nakama's socket that, on a game room's channel (room-…), asks the API (POST /moderation/chat with the room secret) and drops what the moderation hides or cannot check; and the RPC foi_chat_keep, with which the API keeps the party's and the direct messages in Nakama's channels (as their writer, in their history). The image builds it (node apps/nakama/build.mjs: the TypeScript's types stripped into one plain script); the runtime's environment names the API and the room secret (--runtime.env foi_api_url=http://api:8080, foi_room_secret), and the API reaches the RPC with NAKAMA_HTTP_KEY. Nakama's log says five-on-it module: room chat moderated, party and direct messages kept at start.
The game's own client talks to the API for friends, parties and messages and to the game server for a room's chat (Social); the hook is what keeps a client that writes straight to Nakama's socket on a room's channel from getting around the moderation.
The worker's Blender
The worker (apps/worker, POST /generate on 8090, reachable only by the API on the workers network) runs Blender 5.2 for Linux; the image can be checked on its own:
docker build -f deploy/Dockerfile --target worker -t five-on-it-worker:local .
docker run --rm five-on-it-worker:local /opt/blender/blender -b --factory-startup \
--python assets/blender/levels/generate.py -- \
--spec apps/level-editor/assets/kits/test-generated-town.spec.json --out /tmp/outIt generates test-generated-town in about 4 s, container start included, and its four files are byte for byte the ones committed in apps/level-editor/assets/kits/. A new Blender version is a change of BLENDER_VERSION and BLENDER_SHA256 in the Dockerfile (the checksums are in blender-<version>.sha256 on download.blender.org), checked the same way.
Backups
Two volumes hold everything that cannot be rebuilt: five-on-it_postgres (accounts, drafts, builds, games) and five-on-it_minio (the built games). caddy-data holds the certificates, which Caddy can get again.
# Postgres: both databases and the roles, as SQL
docker compose -f deploy/compose.yaml exec -T postgres pg_dumpall -U postgres > foi-backup.sql
# MinIO: the bucket's files (artifacts never change once written, so a live copy is consistent)
docker run --rm -v five-on-it_minio:/data -v "$PWD":/backup alpine tar czf /backup/foi-minio.tgz -C /data .Back up Postgres first and MinIO second: every game version then points at an artifact the copy has. To restore, on empty volumes, before the API and Nakama start, and with MinIO stopped while its files come back (a running MinIO does not see files put under it):
docker compose -f deploy/compose.yaml up -d --wait postgres
docker compose -f deploy/compose.yaml exec -T postgres psql -q -U postgres < foi-backup.sql
docker compose -f deploy/compose.yaml create minio # its volume, nothing started
docker run --rm -v five-on-it_minio:/data -v "$PWD":/backup alpine tar xzf /backup/foi-minio.tgz -C /data
npm run stack:uppsql reports that the databases and the postgres role already exist (the fresh volume made them) and fills them. This sequence was tried on the stack: the games, the accounts and the built artifacts came back, and a room opened from a restored game.
On a VPS from a release
On Hetzner Cloud, stack-server.mjs does all of this from your machine (The platform on Hetzner). Every release carries the stack as a bundle, five-on-it-stack-<version>.tgz (npm run pack:stack): compose.yaml without its build: sections, the Caddyfile, postgres/init.sql, an .env.example naming the release's images and a README. A host needs Docker and nothing else: no clone, no Node, no build.
gh release download v0.42.0 -R ggcapo/five-on-it -p 'five-on-it-stack-*.tgz' # or the release page
tar xzf five-on-it-stack-0.42.0.tgz && cd five-on-it-stack
cp .env.example .env && $EDITOR .env # real secrets, the domain (below)
echo "$TOKEN" | docker login ghcr.io -u <github user> --password-stdin # the images are private
docker compose up -d --wait
docker compose --profile smoke run --rm smoke # the smoke from inside, through the proxyThe images are ghcr.io/ggcapo/five-on-it-<node|nakama|worker|proxy>:<version>, which the Release workflow pushes when the repository variable FOI_PUSH_IMAGES is true (Releasing): from 0.42.1 on (0.42.0's bundle has no images to pull). FOI_IMAGES and FOI_VERSION in .env name them. They are private: the host logs in once with a personal access token (classic) of an account that can read the repository, with the read:packages scope alone, and Docker keeps the login. The bundle was tried on 2026-10-11 from an empty folder: the stack up in 19 s on images already pulled, its smoke from inside passing in 65 s.
For the domain: its A (and AAAA) records point at the host, ports 80 and 443 are open (and 22; Docker's published ports bypass ufw, which is why only the proxy is published), and .env says FOI_SITE_ADDRESS=play.example.com, FOI_HTTP_PORT=80, FOI_HTTPS_PORT=443, FOI_PUBLIC_URL=https://play.example.com, FOI_PUBLIC_WS_URL=wss://play.example.com. Caddy gets the certificate on its first start and keeps it in the caddy-data volume. The Caddyfile is mounted from beside compose.yaml (./Caddyfile:/etc/caddy/Caddyfile:ro), so it is the host's to edit: an e-mail for Let's Encrypt's expiry notices (a global { email you@example.com } block, first), more sites. Every answer carries Strict-Transport-Security, X-Content-Type-Options: nosniff and Referrer-Policy, and no Server header. The smoke service reaches the stack at FOI_SMOKE_URL (the public address by default: the path players take; http://proxy:80 on a stack whose address is plain HTTP) and joins the rooms signed for the public address through it.
The platform on Hetzner
The live platform, https://play.fiveonit.online, is run this way: what runs where, how to reach it and its runbook are in Operations. deploy/fleet/stack-server.mjs puts the whole platform on a Hetzner Cloud server of its own from your machine, with nothing typed on the server, and keeps it up to date:
cp deploy/fleet/.env.fleet.example deploy/fleet/.env.fleet # then fill it in (below)
node deploy/fleet/stack-server.mjs --dry-run # what it would make: the server type checked, its price
node deploy/fleet/stack-server.mjs # makes the server, or brings it up to date
node deploy/fleet/stack-server.mjs --smoke # the same, then the smoke test through the domainWhat .env.fleet needs for it: HCLOUD_TOKEN (a read-write API token of the project), FOI_DOMAIN (the domain: the platform is at it, or at FOI_STACK_HOST under it, play for play.<domain>), FOI_ACME_EMAIL, GHCR_USER and GHCR_TOKEN (a classic token with the read:packages scope alone: the release's images are private), and, before players outside your circle sign in, FOI_LEGAL_OPERATOR, FOI_LEGAL_CONTACT and FOI_LEGAL_COUNTRY. FOI_MODERATORS, FOI_ADMINS, the moderation (FOI_MODERATION, FOI_MODERATION_MODEL, FOI_BLOCKED_WORDS, ANTHROPIC_API_KEY) are passed on when set, and so are who the platform is for and its Steam (FOI_ENTITLEMENT, FOI_JOIN, FOI_STEAM, STEAM_PUBLISHER_KEY, FOI_STEAM_APP_ID, FOI_STEAM_STORE_URL: open by default; add-server gives each game host the same FOI_JOIN) and creation by the platform's model (FOI_CREATOR and its settings, OPENROUTER_API_KEY, or ANTHROPIC_API_KEY for Claude: Operations, on Claude); empty, the server's .env keeps its own. FOI_DEBUG_ACCOUNTS (the accounts whose debug commands every room honours, to test live: Operations) is the one .env.fleet decides outright: empty or left out, the server's is cleared. The SSH key is ~/.ssh/id_ed25519.pub unless SSH_PUBLIC_KEY_FILE names another, and the private network is foi-fleet, 10.0.0.0/16, unless HCLOUD_NETWORK and HCLOUD_NETWORK_RANGE name another. deploy/fleet/.env.fleet.example says every line.
The first run makes the domain's DNS zone at Hetzner and prints its nameservers: the registrar the domain was bought from must name them instead of its own (at Namecheap: Domain List, Manage, Nameservers, Custom DNS), which takes minutes to a day to be seen. It then checks that Hetzner still makes the server type in the location (FOI_STACK_TYPE, cpx32: 4 vCPU and 8 GB, in FOI_STACK_LOCATION, nbg1; it names the ones it does make when not, and says the price), makes the private network, the firewall (22, 80 and 443), the SSH key and the server, on the network at FOI_FLEET_URL's address (10.0.0.2) before it first boots, points the name at it, makes the stack's secrets (the room's and the fleet's written to .env.fleet, where the game hosts take them), copies the release's stack bundle and the server's .env over SSH, logs Docker in to GHCR, pulls the images and starts every service. Caddy gets the certificate once the nameservers answer: run it again then, with --smoke to check the whole path. The server's .env is kept in deploy/fleet/.env.stack (gitignored): its passwords open Nakama's and MinIO's consoles (through an SSH tunnel) and Grafana.
Every later run brings the same server up to date: to this checkout's release (its package.json version) and to .env.fleet, its secrets and any setting changed in its .env on the server kept. npm run fleet:update does the whole update to a release, a backup first and the game hosts after, with that release's own scripts (Operations). Moving to another domain: change FOI_DOMAIN (or FOI_STACK_HOST) and run it again: the new zone is made (its nameservers printed for the new domain's registrar), the name points at the server, the URLs in its .env follow, and Caddy gets the new certificate; the old domain's record stays until you remove it. Game hosts named under the old domain still play, the stack accepting both, until each is removed and added again (remove-server, add-server), which names it under the new one.
Tearing it all down. deploy/fleet/teardown.mjs takes away everything the scripts made on Hetzner, and nothing else of the project (servers and zones labelled fleet=foi, the firewalls, network and SSH key of the names they give theirs):
node deploy/fleet/teardown.mjs # what it would take away, nothing touched
node deploy/fleet/teardown.mjs --yes # takes it away
node deploy/fleet/teardown.mjs --yes --keep-zone # all but the DNS zonesFirst it backs the platform up into deploy/fleet/backups/<server>-<time>/ (gitignored): foi-backup.sql.gz (Postgres, both databases and the roles), foi-minio.tgz (the built games) and the server's .env; when the backup fails nothing is taken away, unless --no-backup says to go on without one (the accounts and the games are then lost). Then every game host and then the platform's server are drained (their players told, their rooms closed within a minute), their records removed and the servers deleted, then the firewalls, the private network and the SSH key, and last the DNS zones with every record in them: the domain then answers nothing until stack-server makes its zone again (check that the registrar still names the nameservers it prints), which --keep-zone spares. The restore is the one under Backups, on a new stack (gunzip foi-backup.sql.gz first). Billing stops with the servers; deploy/fleet/.env.fleet and .env.stack stay on your machine.
Adding game servers on Hetzner
One game server process uses one core and takes 128 players (FOI_CAPACITY). Past what the stack's own game server holds, game hosts on Hetzner Cloud take the rest: each runs a few game processes (FOI_PROCESSES, one per core, on ports 7781 and up) behind Caddy at game<n>.<domain>/g/<port>/, and each process tells the platform API it is there every 5 s. The API places a new room on the server with the most room to spare (its players over its capacity), passes over one that is full, too slow (its loop's 95th percentile step past 8 ms while players are on it) or draining, and sends a city's players to the fullest of its rooms with a place left (GET /api/play?level=, which the page asks before it plays a city online). A server it has not heard from for 30 s is forgotten.
private network 10.0.0.0/16
the stack (10.0.0.2) ───────────────┬──────────────────┐
API :8081 ◄── heartbeats, artifacts │ │
Prometheus ──► /metrics game1 (10.0.0.3) game2 (10.0.0.4)
:7781 … :7784 :7781 … :7784
players ──► wss://play.example.com wss://game1.example.com/g/7781/…What it needs:
- The stack on a Hetzner server of the same project (the private network joins them; a stack at home reaches no host there).
stack-server.mjs(above) makes it so, and everything below with it. - The domain's DNS zone at Hetzner (Hetzner's DNS is part of the Cloud API since its old DNS API closed in May 2026): the scripts name each host
game<n>.<zone>. - The stack's
deploy/.envwithFOI_FLEET_SECRET(openssl rand -hex 32),FOI_FLEET_DOMAIN(the zone),FOI_PRIVATE_IP=10.0.0.2andFOI_API_FLEET_URL=http://10.0.0.2:8081, thennpm run stack:up(ordocker compose up -d --wait). The API then answers its registry, the artifacts and the chat's moderation on10.0.0.2:8081; the proxy never routes/api/fleet…or/api/metrics, and the API refuses any request the proxy forwarded there. deploy/fleet/.env.fleetfrom.env.fleet.example: a read-write API token of the project, the zone,FOI_FLEET_URL=http://10.0.0.2:8081,STACK_SERVER(the stack's server, attached to the network at that address on the first run), the same two secrets (whichstack-serverwrites there), an e-mail for Let's Encrypt and the SSH key.- The server bundle of this release:
npm run pack:server, or the script downloads it from the release (gh).
node deploy/fleet/add-server.mjs --name game1 --dry-run # what it would make
node deploy/fleet/add-server.mjs --name game1 # cpx32 in nbg1, four processes
node deploy/fleet/add-server.mjs --name game2 --type ccx23 --location fsn1 --processes 4
node deploy/fleet/fleet-status.mjs # hosts, players, rooms, € a month and a room-hour
node deploy/fleet/remove-server.mjs --name game1 # drains, then deletesadd-server first checks that Hetzner still makes the server type in the location (it retires types: it names the ones it makes when not, and says the price), then makes what is missing (the network, the firewall: 22, 80 and 443 from anywhere, and UDP 40000-40999 for the data channels, the rest only on the private network; the SSH key), the server (Ubuntu with Docker by cloud-init), its A record, copies the bundle, the Compose file, the Caddyfile and the .env over SSH (the secrets never go into user-data, which Hetzner keeps), starts Caddy and waits until the host's name has its certificate (the platform sends players to a game process as soon as it registers, and they could not reach it before; after two minutes without one it starts Caddy again, which asks Let's Encrypt anew, and after five it stops with the game processes not started), then starts them and waits until every one has registered.
Draining. A game server stops for maintenance without ending a game abruptly: SIGTERM (docker compose down, remove-server, a redeploy) or POST /drain with the room secret (?seconds=) makes it take no new room nor player, tells its players in the room's chat that it restarts in so many seconds (FOI_DRAIN_S, 60; 0 for the desktop app's own server, whose host stopping closes at once), closes their connections as restarting once their rooms are empty or at the deadline, and stops; a second signal stops it at once. The page says the server is restarting and plays again a few seconds later, wherever the platform places the city then. A page turned away because a server is full says so and tries again in 30 s.
Several processes on one host. FOI_PROCESSES=<n> makes the server a supervisor of n processes on consecutive ports from PORT, each with {port} in its settings filled in (FOI_SERVER_ID=game1-{port}, FOI_PUBLIC_URL=wss://game1.example.com/g/{port}), started again with a backoff (1 s, doubling to 30 s) when one stops; SIGTERM is passed to each, which drains.
The data channel
The game server can carry the inputs and the snapshots over a WebRTC data channel, UDP beside the WebSocket (Netcode); a page that cannot reach it stays on the WebSocket, so the channel is never needed to play.
- The stack keeps it off (
FOI_RTC=0). The game service publishes a small UDP range,FOI_RTC_PORTS(40000-40019: one channel a port,FOI_RTC_MAX_PEERS=20), the same numbers outside and in, on 127.0.0.1 untilFOI_RTC_BIND. To turn it on for players elsewhere:FOI_RTC=1,FOI_RTC_PUBLIC_IPthe host's public address (the container's own is not reachable; the server announces this one in its candidates),FOI_RTC_BIND=0.0.0.0, and the range open in the host's firewall (Docker's published ports bypassufw). More players on the channel need a longer range and as many peers. - Behind ngrok leave it off: ngrok carries no UDP. Turned on, the pages would try for 8 s and stay on the WebSocket (counted as
timeoutfall backs). - The fleet's hosts turn it on (
deploy/fleet/render.mjs):FOI_RTC=1on UDP 40000-40999, which the firewall opens to anyone and the supervisor cuts among the processes (250 ports each with four), bound to and announcing the host's public address (FOI_RTC_ADDRESSES,FOI_RTC_PUBLIC_IP: the hosts use the host's network, so there is no NAT to cross, and one address means one port a channel). - The desktop app's Play local keeps it off: a game hosted on a home network needs no UDP ports opened.
/health counts the channels under rtc (open, on their way, opened, fall backs by reason), the metrics are foi_rtc_peers, foi_rtc_handshakes, foi_rtc_opened_total, foi_rtc_fallbacks_total{reason}, foi_rtc_bytes_total{direction} and foi_rtc_worker_stops_total, the rooms dashboard shows the players on UDP and the fall backs by reason, and the RtcFallbacksSpike alert fires when over 5 fall backs in 10 minutes are also over a third of the channels opened (a closed firewall, a wrong public address). The server bundle carries the channel's worker thread as rtc-worker.mjs beside server.mjs (werift, 1.7 MB): keep the two together.
Monitoring
docker compose --profile monitoring up -d adds Prometheus (15 days kept; on 127.0.0.1:9090), Grafana on /grafana behind its login (user admin, GRAFANA_ADMIN_PASSWORD in .env: it does not start without one) and a node-exporter for the stack's host; COMPOSE_PROFILES=monitoring in .env makes npm run stack:up (and docker compose up) start them every time. Prometheus scrapes the API, the stack's game server and every game process and host of the fleet, which it finds through the API's registry. Four dashboards come provisioned (deploy/monitoring/): the fleet (servers, players against capacity, the loop's p99 step, late steps, snapshot bandwidth, the hosts' CPU, steal, memory), the rooms (players, snapshots, entities per client, AI step, resyncs), the API (requests by route, p95, errors, the processes) and the clients (what the players' games report with their consent: crashes and sessions by release and platform, crashes per hundred sessions, frame times). The alerts (the loop's p99 over 10 ms, a server lost without draining, a target down, a disk over 85 %, CPU steal over 10 %, players falling back from the data channel) show in both; Alertmanager or a Grafana contact point sends them on.
Privacy, terms and telemetry
The game carries a privacy policy and terms of use (?privacy, ?terms on the stack's address, linked from the main menu, the pause menu, the game pages and the sign-in form, in English and Italian). They name who runs the platform as FOI_LEGAL_OPERATOR, FOI_LEGAL_CONTACT and FOI_LEGAL_COUNTRY say (the API's /info), in neutral words while those are empty; set them before players outside your circle sign in. A sign-in keeps which version of the terms it accepted (the API's consents).
At its first start the game asks whether it may send crash reports and anonymous statistics; both stay off until the player says yes, and Settings → Privacy changes the answer. What a player agrees to goes to the API (POST /api/telemetry/crash, POST /api/telemetry/events): no name, no account, no address kept, an install id the player can renew; it is forgotten after FOI_TELEMETRY_DAYS (90). Moderators read the crashes grouped by fingerprint (GET /api/telemetry/crashes?days=7, see Moderation), and the Clients dashboard of the monitoring profile shows the counts.
Updating
From a release: download its bundle, keep .env (its FOI_VERSION set to the new release), then docker compose pull && docker compose up -d --wait. From the repository: pull it and run npm run stack:up again. Either way the changed services are recreated, the API brings its schema up to date at start (its migrations are an ordered list, recorded in foi_migrations) and Nakama migrates its own. A bundle's Caddyfile is the host's own and is kept: one from before the MCP's OAuth lacks its routes, so add the release's three handle blocks for /.well-known/oauth-authorization-server, /oauth/* and /.well-known/oauth-protected-resource* (or take its Caddyfile whole). Back up the volumes first (above).
Not production-ready yet
- Game hosts by script. The fleet grows and shrinks by
deploy/fleet/(above), by hand: nothing adds a host when the servers fill up. - No Redis. Builds are jobs of a durable queue kept in Postgres, run inside the API process (one at a time by default,
FOI_BUILD_CONCURRENCY), and the daily quotas are counted in Postgres too: a restart loses neither (Platform). Redis comes with the first thing that needs it: rate limits. - Broad credentials. The API and Nakama use the Postgres superuser, and the API MinIO's root user; a role per database and an access key limited to the bucket are to do.
- MinIO. The upstream project no longer publishes images; the stack runs a community build. Any S3-compatible store works (
S3_ENDPOINT), a managed one included. - Operations. Metrics and alerts (the
monitoringprofile) but no log shipping (logs aredocker compose logs) and no scheduled backups; the alerts reach nobody until Alertmanager or a Grafana contact point is set up. - Not there yet: moderation on by default (
FOI_MODERATION=chainwith a key), more than one worker (builds are generated one at a time).
The game server's metrics and switches
Every game server answers GET /metrics with its Prometheus metrics (the loop's step time, every room's players, snapshots, AI and bytes, the process's CPU and memory: Performance); the Compose stack's proxy does not route it, so it is read on the internal network. Its snapshots are deltas, the far entities' poses sent less often and the frames compressed by default; FOI_DELTA=0, FOI_AOI_TIERS=0 and FOI_WS_DEFLATE=0 turn each off. FOI_RTC=1 offers the WebRTC data channel (above).