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,listPublicandquickJointake the room host’s address asrouter. 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.collectionandroom.leaderboardand the client SDK’sdataClientwork the same, with the same rights, errors and 5-second room cache, againstsettings.datafrom the build orIRT_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_DATArather than to object storage, and every change is appended there as it happens. Player data, collections and leaderboards are in SQLite atIRT_DATA/data.sqlite, written before each call answers. Back up that directory; it is the only copy.rooms.retentionother 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 atapi.irt.ioisn’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, throughPOST /v1/rooms, join codes, quick join or connecting, soplayers.createRoomsmust be on; with it off, the only rooms are those already inIRT_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.