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.
Running the node
Section titled “Running the node”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-stoppedThe image defaults to the Forge backend, so no extra configuration is needed:
SELF_HOSTED_NODE_SERVER_KEY=… docker compose up -dTo 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/DockerfileYou need a Rust toolchain, a JDK (17+), Maven, and a full checkout:
git clone --recurse-submodules https://github.com/witchesofthehill/manabrew.gitcd manabrewBuild the Forge harness jar and the cardset archive once:
mvn -pl forge-harness -am package -DskipTestscargo run --release -p forge-cardset-archive --features build --bin build-cardset-archiveThen run the node:
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 \JAVA_HOME="$(/usr/libexec/java_home)" \cargo run --release -p self-hosted-node --features java-forge$(/usr/libexec/java_home) resolves the JDK path on macOS; on Linux point
JAVA_HOME at your JDK installation (for example
/usr/lib/jvm/temurin-21-jdk).
Hosting multiple rooms
Section titled “Hosting multiple rooms”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-stoppedIn 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:
SELF_HOSTED_NODE_SERVER_KEY=… docker compose up -d --scale forge-rooms=3Each 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.
Building the native engine yourself
Section titled “Building the native engine yourself”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:
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-forgenative-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.
Configuration
Section titled “Configuration”All settings are environment variables:
| Variable | Default | Purpose |
|---|---|---|
SELF_HOSTED_NODE_RELAY_URL | ws://127.0.0.1:9443 | Relay server to connect to |
SELF_HOSTED_NODE_SERVER_KEY | - | Relay server key (must match the relay’s) |
SELF_HOSTED_NODE_ROOM_NAME | Self-Hosted Node | Lobby name shown to players |
SELF_HOSTED_NODE_ROOM_PASSWORD | none | Require a password to join |
SELF_HOSTED_NODE_FORMAT | any | Game format (e.g. commander) |
SELF_HOSTED_NODE_MAX_PLAYERS | 4 | Seats in the room |
SELF_HOSTED_NODE_MAX_GAMES | 1 | Rooms the node hosts (one concurrent game each) |
SELF_HOSTED_NODE_ENGINE_BACKEND | manabrew | forge for the Forge engine, manabrew for the ManaBrew engine |
SELF_HOSTED_NODE_BOT_ENABLED | false | Seat an AI bot in the room |
SELF_HOSTED_NODE_AUTO_START | false | Start as soon as the room fills |
Shutdown and draining
Section titled “Shutdown and draining”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:
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.