Build and deploy

Self-hosting

Running a build on your own machine with the open-source room host, in Docker or behind Caddy, and how it differs from hosted IRT.

The room host in @irt/room is open source and runs on its own. Given a build, it serves the client protocol over WebSocket, the client HTTP API and the site from one process, with room state on disk. The client SDKs work against it as they do against the router, so a project can move between IRT and its own machine by changing the router address.

Self-hosting is one process on one machine. The VM layer, placement across hosts and hibernation to object storage are part of hosted IRT and aren’t open source.

Running a build

irt build writes the build tarball to .irt/build/build.tar.gz and leaves its unpacked files in .irt/build/stage: irt-build.json, each room type’s bundled module in room/ and the site in site/. Pass either directory to irt-room-host:

irt build
npx irt-room-host .irt/build/stage

Or unpack the tarball where it will run:

mkdir -p /srv/my-game
tar -xzf .irt/build/build.tar.gz -C /srv/my-game
npx irt-room-host /srv/my-game

Given a build directory, the room host serves each of its room types, native types included, with each type’s own size, maxPlayers and retention, its site with the build’s site.crossOriginIsolated, and the settings declared in irt.config.ts. It also takes a single bundled room module in place of a directory, as the one type default.

It logs one JSON line, {"level":"info","msg":"listening",...}, when it is ready. On SIGTERM or SIGINT it drains: it stops taking connections, closes the open ones with 1001 so clients reconnect with backoff, and hibernates every room to disk, running onHibernate. Rooms that haven’t saved within 8 seconds are stopped, and wake from their last change on disk, as after a crash.

Configuration

The room host is configured by environment variables, and checks them all at startup. A configuration it can’t run with exits with code 2 and one line per problem.

Variable Default Meaning
IRT_PORT 7350 Port for the client protocol at /v1/ws, the client HTTP API, /healthz and the site
IRT_HOSTNAME 127.0.0.1 Address to listen on; 0.0.0.0 for every interface
IRT_DATA .irt Directory for room state and join codes; it must be writable
IRT_SIGNING_KEYS the key in $IRT_DATA/key Comma-separated HS256 secrets that verify player tokens, each at least 32 characters
IRT_GUEST_KEY the key in $IRT_DATA/key HS256 secret that signs and verifies guest tokens, at least 32 characters and not one of the signing keys
IRT_SETTINGS none Project settings as JSON, for those the build doesn’t declare
IRT_SITE none A site directory to serve, with a room module only
IRT_CROSS_ORIGIN_ISOLATED 0 1 serves IRT_SITE with COOP and COEP, with a room module only
IRT_LIVEKIT_URL none A LiveKit server for voice, such as wss://voice.example.com
IRT_LIVEKIT_KEY, IRT_LIVEKIT_SECRET devkey, secret That server’s API key and secret
IRT_INSPECTOR 0 1 serves the inspector; it shows every player’s private state, so leave it off on a server

Generate keys with openssl rand -base64 32. Your backend signs player tokens with a signing key exactly as it does for a hosted project.

Keys and rotation

Tokens are checked as the router checks them. A player token must be signed with one of IRT_SIGNING_KEYS, name the project the client connects to in prj, and live at most 24 hours. A guest token must be signed with IRT_GUEST_KEY; a signing key can’t sign one, and the guest key can’t sign a player token.

To rotate a signing key, add the new key to IRT_SIGNING_KEYS and restart, move your backend to the new key, then remove the old one and restart again. Tokens signed with any listed key are accepted.

Guest tokens last 30 days and keep a guest’s player ID across visits. Set IRT_GUEST_KEY so they keep working when IRT_DATA is lost or the room host moves to another machine. Changing it turns every returning guest into a new one.

Without either variable, the room host generates a key in $IRT_DATA/key and uses it for both, as irt dev does.

Settings

players.*, rooms.size, rooms.retention and voice.* apply as they do hosted. Settings declared in irt.config.ts belong to the build, as they do for a hosted project; IRT_SETTINGS sets the rest, and setting one the build declares is a startup error:

IRT_SETTINGS='{"players":{"quickJoin":true},"rooms":{"size":"large"}}' npx irt-room-host /srv/my-game

Docker

The repository has a Dockerfile that builds the room host on Node 24 and runs it as an unprivileged user, with room state in a volume at /data and the build at /build:

docker build -f deploy/self-host/Dockerfile -t irt-room-host .
docker run -d --name my-game -p 7350:7350 \
  -v my-game-data:/data \
  -v /srv/my-game:/build:ro \
  -e IRT_SIGNING_KEYS -e IRT_GUEST_KEY \
  irt-room-host

The image listens on 0.0.0.0:7350, and its health check calls /healthz, which answers 200 ok, or 503 while draining. docker stop drains the room host within its 10-second grace period. A bind mount at /data must be writable by uid 1000.

TLS with Caddy

The room host speaks plain HTTP and WebSocket. Put a reverse proxy in front of it for TLS. With Caddy, which gets certificates by itself and proxies WebSocket upgrades:

game.example.com {
	reverse_proxy 127.0.0.1:7350
}

Clients then use https://game.example.com as their router address, and the site is served from the same origin. Behind a proxy, keep IRT_HOSTNAME at 127.0.0.1 so the room host is reachable only through it.

Self-hosted connections use WebSocket only. WebTransport and its datagrams need the router, so unreliable messages travel over the WebSocket and arrive in order. On an https:// address the client SDK tries WebTransport once at /v1/wt, fails, and uses WebSocket from then on.

What’s the same

  • The protocol and the client SDKs. connect, createRoom, joinByCode, listPublic and quickJoin take the room host’s address as router. A client switches between hosted and self-hosted by that URL alone.
  • The room SDK. The same build runs in both, with the same hooks, hibernation and wake, room state versions and migrations.
  • Limits and close codes. Message size and rate, connections per room by size and maxPlayers, room state size, token lifetime and the close codes are the hosted ones. Rooms hibernate 30 seconds after their last connection leaves.
  • Tokens and guests, as above.
  • Player data, collections and leaderboards. Room code’s room.playerData, room.collection and room.leaderboard and the client SDK’s dataClient work the same, with the same rights, errors and 5-second room cache, against settings.data from the build or IRT_SETTINGS. See the guide.

What’s different

  • One process. All rooms share one Node event loop and one machine. There is no placement, no VM per tenant and no CPU or memory reservation per room.
  • Room state and player data on disk. Rooms hibernate to IRT_DATA rather than to object storage, and every change is appended there as it happens. Player data, collections and leaderboards are in SQLite at IRT_DATA/data.sqlite, written before each call answers. Back up that directory; it is the only copy. rooms.retention other than "none" isn’t enforced, so hibernated rooms are kept until you delete them.
  • The client HTTP API only. The room host serves /v1/ws, POST /v1/rooms, POST /v1/quick-join, GET /v1/codes/{project}/{code}, GET /v1/rooms/{project}, POST /v1/guest, POST /v1/data/{project} and /healthz. Data calls have no rate limits and guest minting no per-address cap, so put a proxy’s limits in front of a public room host. The HTTP API at api.irt.io isn’t there: no server API calls into rooms, no room deletion, no bans, and no logs or stats endpoints. Webhooks aren’t sent. Rooms are created only by clients, through POST /v1/rooms, join codes, quick join or connecting, so players.createRooms must be on; with it off, the only rooms are those already in IRT_DATA.
  • No hosted backend, no WebTransport, no metering, billing, spending limits or dashboard. Close code 4009 never happens.
  • Voice needs a LiveKit server of your own, set by IRT_LIVEKIT_URL.
  • Projects. The room host serves whatever project slug a client names, with one set of settings and keys for all of them.

View this page as Markdown