Skip to content

Self-hosting

One process, one port; TLS terminates at a reverse proxy. The shipped docker-compose.yml runs the rooms node behind Caddy (automatic Let’s Encrypt for your DOMAIN, WebSocket upgrades transparent) and serves this static site from the same origin.

Terminal window
git clone <rooms_sdk> && cd rooms_sdk
# 1. DNS: point your domain's A/AAAA record at this machine.
# 2. Secrets:
cat > .env <<EOF
DOMAIN=rooms.example.com
ACME_EMAIL=you@example.com
GITHUB_TOKEN=<token with access to the private physics dependency>
EOF
# 3. Node config (see the reference below):
cp deploy/node.example.json node.json # then edit secrets
# 4. Launch:
docker compose up -d

What comes up:

| Service | Role | | --- | | rooms-node | the node binary (packages/node): /v1/* HTTP API + /play/<roomKey> WebSockets on one port, SQLite in the rooms-data volume | | site | one-shot init container that builds this site and publishes dist/ into the shared rooms-site volume | | caddy | TLS (HTTP/2 + HTTP/3), proxies /v1/* and /play/* to the node, serves everything else (docs, llms.txt, /spec/*) from the site volume |

The image is a multi-stage build: the build stage compiles the ReactPhysics3D native library and the AOT bundle (dart build cli — native assets linked by build hooks); the runtime is debian:bookworm-slim, unprivileged, with libsqlite3.

Field Type Default / rule Meaning
publicBaseUrl string — Origin used to build room URLs (wss://…/play/<key>); must match the public domain behind Caddy
nodeSecret string — HMAC key for join/creator tokens; generate 32+ random characters
apiKeys object — Service keys (name → secret) seeded into the api_keys table; sent as Authorization: Bearer <key>
dataDir string /var/lib/rooms SQLite directory (persist it — join-token consumption and room pins survive restarts)
listen.port int 8787, 1024–65535 Node listen port (plain HTTP; TLS is the proxy’s job)
listen.address string 0.0.0.0 Bind address
allowedOrigins string[] — Origin allowlist enforced before the WebSocket upgrade
limits.maxRooms int 256 Concurrent rooms; beyond → quota_exceeded
limits.maxReservedCapacity int 4096 Sum of room capacities; beyond → quota_exceeded
limits.idleTtlSeconds int 300 Idle rooms stop ticking but keep room + state in memory
limits.connectionsPerIpPerMinute int 60 Pre-upgrade per-IP throttle
joinTokenTtlSeconds int 900 (60–86400) Minted join-token lifetime
movement bool true Wire the reactphysics3d movement backend (false → movement rooms return unsupported_template)

Config is fail-fast: unknown or out-of-range fields abort startup.

deploy/Caddyfile:

{$DOMAIN} {
encode zstd gzip
handle /v1/* {
reverse_proxy rooms-node:8787
}
handle /play/* {
reverse_proxy rooms-node:8787
}
handle {
root * /srv/site
try_files {path} {path}/
file_server
}
}
  • Set DOMAIN (and optionally ACME_EMAIL) in the compose environment; certificates are issued and renewed automatically.
  • HTTP/3 is enabled (443/udp is published).
  • Node routes are explicit; everything else is the static site, so https://your-domain/ is the docs, https://your-domain/v1/… the API, https://your-domain/llms.txt the agent index.
  1. A server with Docker and ports 80/443 (TCP + UDP 443) open.
  2. DNS A/AAAA record for your domain → server IP (must resolve before Let’s Encrypt will issue).
  3. .env with DOMAIN, ACME_EMAIL, GITHUB_TOKEN (read access to the private reactphysics3d_dart dependency for the image build).
  4. node.json from deploy/node.example.json — new nodeSecret, real apiKeys, your game origins in allowedOrigins, publicBaseUrl = https://<DOMAIN>.
  5. docker compose up -d --build; check docker compose ps and docker compose logs caddy.
  6. Smoke test: curl -s https://<DOMAIN>/v1/rooms → 401 (Caddy + node + auth all working); https://<DOMAIN>/llms.txt → this site.
  7. Backups: the rooms-data volume (SQLite). Room state is memory-only by design; rooms are lazily recreated from their pinned config on the next join after a restart.
Terminal window
cd packages/node
cp ../../deploy/node.example.json /tmp/node.json # then edit secrets
dart run bin/node.dart --config /tmp/node.json # movement physics included

The node handles SIGINT/SIGTERM by draining (rooms closed, peers get 1001). See the HTTP API reference for the endpoint contract and its “Node implementation notes”.

This compose file serves the docs from the same origin as the node (https://<DOMAIN>/), which is the single-origin convenience path. The site is plain static output, so it can equally be deployed to Cloudflare Pages (docs there, node here — see site/README.md for the wrangler and git-integrated paths). That is two origins and nothing breaks:

  • the site’s internal links are same-origin paths (/docs/…, /llms.txt, /spec/…) — they resolve under whichever domain serves them;
  • the hosted API’s base URL is not hardcoded anywhere in the site: the API reference documents https://<your-node-domain>/v1/…, and each node deployment advertises its own origin via publicBaseUrl in node.json (it builds the room URLs clients connect to).

Generated from docker-compose.yml, deploy/Caddyfile and deploy/node.example.json in the repository.