Skip to content

Self-hosting a Forge room

A self-hosted node is a headless room host. It connects to a relay server, opens a lobby room, and runs the games for everyone who joins - players only need the web or desktop client. With the forge backend the node spawns one Forge process per game, so games are played on the original Forge rules engine rather than the Rust port.

The node authenticates to the relay with the relay’s server key. Set it via SELF_HOSTED_NODE_SERVER_KEY - never commit it anywhere.

The image is published on GHCR and bundles everything: the node binary and Forge compiled ahead of time by GraalVM native-image - no JVM, no jar, no checkout needed. Create a compose.yml:

services:
forge-room:
image: ghcr.io/witchesofthehill/manabrew-node:latest
environment:
SELF_HOSTED_NODE_RELAY_URL: "wss://relay.manabrew.app"
SELF_HOSTED_NODE_SERVER_KEY: "${SELF_HOSTED_NODE_SERVER_KEY:?required}"
SELF_HOSTED_NODE_ROOM_NAME: "my-room"
RUST_LOG: "self_hosted_node=info"
restart: unless-stopped

The image defaults to the Forge backend, so no extra configuration is needed:

Terminal window
SELF_HOSTED_NODE_SERVER_KEY=… docker compose up -d

To build the image yourself instead of pulling, replace the image: line with a build: block (needs a full checkout - git clone --recurse-submodules https://github.com/witchesofthehill/manabrew.git):

build:
context: .
dockerfile: manabrew-rs/crates/self-hosted-node/Dockerfile

One node can host any number of rooms - set SELF_HOSTED_NODE_MAX_GAMES and it opens that many lobbies, suffixed my-room (1), my-room (2), …:

services:
forge-rooms:
image: ghcr.io/witchesofthehill/manabrew-node:latest
environment:
SELF_HOSTED_NODE_RELAY_URL: "wss://relay.manabrew.app"
SELF_HOSTED_NODE_SERVER_KEY: "${SELF_HOSTED_NODE_SERVER_KEY:?required}"
SELF_HOSTED_NODE_ROOM_NAME: "my-room"
SELF_HOSTED_NODE_MAX_GAMES: "4"
RUST_LOG: "self_hosted_node=info"
restart: unless-stopped

In multi-room mode the format is forced to any - players pick per game.

In the published image every room shares one engine and therefore one copy of the 34,000-card database, which is the bulk of the memory. Four rooms fit in about a gigabyte; a fifth costs far less than the first. Set SELF_HOSTED_NODE_SHARED_ISOLATE=0 to give each game its own isolate instead, which isolates rooms from one another at the price of reloading the card database per game.

To scale across containers instead (e.g. to spread rooms over hosts or set per-room memory limits), run several replicas of the same service:

Terminal window
SELF_HOSTED_NODE_SERVER_KEY=… docker compose up -d --scale forge-rooms=3

Each replica is an independent node with its own rooms, so total rooms = replicas × SELF_HOSTED_NODE_MAX_GAMES. Replicas share the same SELF_HOSTED_NODE_ROOM_NAME; give each its own service entry instead if you want distinct lobby names.

The published image compiles Forge ahead of time with GraalVM native-image into a shared library the node links and calls in-process. To reproduce that from a checkout rather than pulling the image:

Build the library (needs GraalVM for JDK 21 - see the native build README), then run the node with the graal-forge feature instead of java-forge:

Terminal window
yarn build:harness
./forge-harness/build-native.sh
SELF_HOSTED_NODE_ROOM_NAME=my-room \
SELF_HOSTED_NODE_SERVER_KEY=<your-server-key> \
SELF_HOSTED_NODE_ENGINE_BACKEND=forge \
SELF_HOSTED_NODE_RELAY_URL=wss://relay.manabrew.app \
cargo run --release -p self-hosted-node --features graal-forge

native-image cannot cross-compile, so build the library on each platform you intend to run the node on.

The older backend spawns one Forge process per game against a JRE and the harness jar. It is still supported - build with --features java-forge as shown under “From source” - but the published image no longer ships a JVM.

All settings are environment variables:

VariableDefaultPurpose
SELF_HOSTED_NODE_RELAY_URLws://127.0.0.1:9443Relay server to connect to
SELF_HOSTED_NODE_SERVER_KEY-Relay server key (must match the relay’s)
SELF_HOSTED_NODE_ROOM_NAMESelf-Hosted NodeLobby name shown to players
SELF_HOSTED_NODE_ROOM_PASSWORDnoneRequire a password to join
SELF_HOSTED_NODE_FORMATanyGame format (e.g. commander)
SELF_HOSTED_NODE_MAX_PLAYERS4Seats in the room
SELF_HOSTED_NODE_MAX_GAMES1Rooms the node hosts (one concurrent game each)
SELF_HOSTED_NODE_ENGINE_BACKENDmanabrewforge for the Forge engine, manabrew for the ManaBrew engine
SELF_HOSTED_NODE_BOT_ENABLEDfalseSeat an AI bot in the room
SELF_HOSTED_NODE_AUTO_STARTfalseStart as soon as the room fills

SIGTERM or SIGINT shuts the node down immediately: every room closes, and any game in progress ends.

SIGUSR1 drains instead. The node stops starting new games, each room closes itself as soon as it has no game running, and the process exits 0 once the last room is gone - games in progress run to completion. Use this to retire a node (for example to replace it with one on a new configuration) without kicking players mid-game:

Terminal window
kill -USR1 <node-pid>
# or, in Docker:
docker kill --signal=USR1 <container>

With restart: unless-stopped, Docker restarts the container after the drain exit and the old configuration comes back. Bring up the replacement first, then docker compose stop the drained service (or remove it from the compose file) once its games have finished.