# 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`:

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

Or unpack the tarball where it will run:

```sh
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](/docs/concepts#rooms), 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](/docs/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](/docs/http-api#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](/docs/dev-tools#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:

```sh
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`:

```sh
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](https://caddyserver.com), which gets
certificates by itself and proxies WebSocket upgrades:

```text
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](/docs/limits#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](/docs/guides/player-data).

## 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](/docs/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.
