Self Hosting - datstarkey/ww-online GitHub Wiki
A WW-Online room is a small relay server. Usually the host's app runs it: Host a room starts one on the host's PC. A dedicated server runs it on its own, on an always-on machine (a VPS, a home server, a Raspberry Pi), so the room is up even when the host isn't playing, and nobody's PC has to be reachable from the internet.
You probably don't need this. If your friends can reach your PC (the same home network, a shared Tailscale network, or port
6969forwarded on your router), just press Host a room in the app and they join your address. A dedicated server is only worth it when you want a room that stays up without you, or when nobody in the group can make their PC reachable.
The server is published as a Docker image for linux/amd64 and linux/arm64:
ghcr.io/datstarkey/ww-online-server
It needs no game files, no GPU and almost no resources. The smallest VPS is plenty.
docker run -d --name ww-online --restart unless-stopped -p 6969:6969 ghcr.io/datstarkey/ww-online-server:latest
Then everyone opens WW-Online, enters the server's address in Server host (port 6969) and presses Join. Check it's up with curl http://<server>:6969/health or docker logs ww-online.
deploy/docker-compose.yml is a ready-made example with every setting. Copy it to the server, edit the environment, then:
docker compose up -d # start it (it restarts on reboot and after a crash)
docker compose logs -f # watch players join
Everything is set through environment variables (-e NAME=value, or environment: in compose).
| Variable | Default | What it does |
|---|---|---|
WWO_PORT |
6969 |
The port the server listens on inside the container. Change the -p mapping to match, e.g. -p 7000:7000 -e WWO_PORT=7000. (To only change the port players use, change the left side of the mapping instead: -p 7000:6969.) |
WWO_SHARED_WALLET |
true |
The room's starting rules. true/false (also 1/0, yes/no, on/off). The room owner can change them any time from the Room page, so these only set how a fresh room starts. false for the seven shared-progress rules and WWO_ALLOW_WARPING (with projectiles left true) is the Co-op preset. |
WWO_SHARED_WORLD |
true |
|
WWO_SHARED_ITEMS |
true |
|
WWO_SHARED_STORY |
true |
Shared story: story, cutscene and side-quest event flags, the Nintendo Gallery figurines Carlov has made, the dungeon warp jars, Beedle's point card, the postbox letters and side-quest progress (counts, levels and prizes won). |
WWO_SHARED_BAIT |
true |
Shared bait bag: the bait bag's All-Purpose Bait and Hyoi Pears are one room total. |
WWO_SHARED_SPOILS |
true |
Shared spoils bag: the spoils bag's counts (Joy Pendants, Skull Necklaces, Chu Jellies...) are one room total. |
WWO_SHARED_DELIVERY |
true |
Shared delivery bag: the delivery bag's quest items (trade goods, letters, Cabana Deed, Beedle's tickets) are one room bag, and Windfall's pedestals are the room's. |
WWO_SHARED_PROJECTILES |
true |
Other players' projectiles: their bombs, boat-cannon shots and arrows are real in your world (they fly, explode and hit your enemies and walls). false drops them; their carried bomb and aim poses still show. On in both presets. |
WWO_ALLOW_WARPING |
true |
Allow warping: players can warp to each other from the Room page's Players list (same area: next to them; anywhere else: the entrance they came in through). It gets a player unstuck when shared story or world progress closes their way forward (softlocks). false hides the button and the server stops passing on where players entered their area. Off in Co-op. |
WWO_OWNER_KEY |
none | A secret. The player who enters it in the app's Owner key field becomes the room owner. See The room owner. |
WWO_LOG_FILE |
none | Also write the log to this file, e.g. /data/server.log (mount a volume on /data to keep it). The log always goes to the console, which is what docker logs shows. |
A bad value (a port that isn't a number, a rule that isn't true or false, an owner key over 256 characters) stops the server at start-up with a message saying which variable is wrong.
In Docker, set the port with WWO_PORT, not a command-line argument. The image's health check reads WWO_PORT to know which port to poll, so a port passed as an argument (docker run ... ww-online-server 7000) would leave the container marked unhealthy even though the server works.
Log file on a bind mount: the server runs as UID 1654 (the image's app user). A named volume on /data just works; a host folder mounted there (-v ./logs:/data) must be writable by that UID, e.g. sudo chown 1654 ./logs, or the server can't create WWO_LOG_FILE (it logs an error and carries on with the console log).
Outside Docker, the server takes the same variables, plus command-line flags, which win over the variables: WWOnline.Server [port] [--owner-key <key>] [--log-file <path>] [--no-shared-wallet] [--no-shared-world] [--no-shared-items] [--no-shared-story] [--no-shared-bait] [--no-shared-spoils] [--no-shared-delivery] [--no-shared-projectiles] [--no-warping]. --help lists them and --version prints the version.
The room owner picks the rules (Full sync, Co-op or a mix), and their game seeds the room's items, story, wallet, bait bag, spoils bag and delivery bag when the room is new.
- Without an owner key, the first player to join owns the room. If they leave, the player who joined earliest after them takes over.
-
With
WWO_OWNER_KEY, the player who enters that key in the app's Owner key field (under Server host, before pressing Join) owns the room, even if someone else joined first. The app sends it again after every reconnect, so the owner keeps the room. While the key holder isn't connected, the earliest joiner owns the room, and the first player to join an empty room seeds it. While the key holder is connected, their game seeds an empty room; if it hasn't within 30 seconds (say they're still in the menus), a joiner seeds it so nobody is stuck waiting.
Pick a long random key (openssl rand -hex 16) and give it only to whoever should run the room. Everyone else leaves the field empty. A wrong key is ignored (the server logs ClaimRoomOwner rejected): that player just joins as a normal player. After 5 wrong keys on one connection the server stops listening to that connection's claims (the player has to leave and join again to retry), so guessing is slow and can't flood the log. The app forgets the key when you change the address or leave, so it's never sent to a different server. The Room page shows the owner badge to whoever owns the room.
Without TLS (below), the key crosses the internet unencrypted, like everything else the app sends. It stops a friend from taking the room by accident. It is not a password for the server: anyone who knows the address can still join.
The server uses one port, TCP 6969 by default (SignalR over WebSockets, plus HTTP for /health). Nothing else needs opening.
- VPS: allow inbound TCP 6969 in the provider's firewall (security group, cloud firewall).
- Home server: forward TCP 6969 on the router to the machine running Docker.
-
ufw users: Docker's published ports bypass ufw rules.
-p 6969:6969is reachable from anywhere even if ufw denies it. To limit it, publish on one address only, e.g.-p 100.101.102.103:6969:6969(a Tailscale IP) or-p 127.0.0.1:6969:6969(behind a reverse proxy on the same machine).
- Tailscale (easiest and private): run the container on any machine in your tailnet and share the machine with your friends (or have them join your tailnet). They connect to its Tailscale IP or MagicDNS name, port 6969. Nothing is exposed to the internet and no router changes are needed. Publish on the Tailscale IP only (see above) to keep it off your LAN and the internet.
- A VPS: any small Linux VPS with Docker. arm64 machines (Oracle's free Ampere instances, a Raspberry Pi 4/5) work too: Docker pulls the right architecture.
- Latency matters more than bandwidth. Pick a location near your players. The server relays each player's position many times a second, but the messages are small.
By default the app talks to the server over plain HTTP/WebSockets. To encrypt it (and hide the owner key), put the server behind a reverse proxy with a certificate, and players enter the full URL in Server host, e.g. https://wwo.example.com. With a URL there the app ignores the Port field: the port is the URL's own, or 443 for https:// and 80 for http:// when it has none (https://wwo.example.com:8443 for another). Only http:// and https:// URLs work; the app says so for anything else (ws:// and so on). Players on older app versions can't connect through a URL: everyone in a room needs the same version anyway.
Caddy gets the certificate by itself and proxies WebSockets without extra settings. A Caddyfile:
wwo.example.com {
reverse_proxy ww-online:6969
}
With both in one compose file (the WW-Online service needs no ports: then, only Caddy publishes 80 and 443):
services:
ww-online:
image: ghcr.io/datstarkey/ww-online-server:latest
restart: unless-stopped
environment:
WWO_OWNER_KEY: "change-me"
caddy:
image: caddy:2
restart: unless-stopped
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
volumes:
caddy-data:Other proxies (nginx, Traefik) work too if they pass WebSocket upgrades (Upgrade/Connection headers) and don't time out idle connections too quickly. SignalR pings every 15 s. A URL with a path (https://example.com/wwo/gamehub) is used as it is, for a proxy that serves the hub under a sub-path.
GET /health answers 200 with:
{"status":"ok","app":"WW-Online","version":"0.7.0","commit":"<git sha>","protocol":12,"players":2}The image's HEALTHCHECK polls it, so docker ps shows healthy. Point an uptime monitor at it if you like. version and protocol are what players' apps must match (see Updating).
docker logs -f ww-online shows joins and leaves, room rule changes, what each player added to the shared world, items and story, and a [stats] line every 10 seconds while players are connected.
Pull the new image and recreate the container:
docker compose pull && docker compose up -d
# or, without compose:
docker pull ghcr.io/datstarkey/ww-online-server:latest
docker rm -f ww-online && docker run -d --name ww-online --restart unless-stopped -p 6969:6969 ghcr.io/datstarkey/ww-online-server:latest
Tags:
-
latest: the newest stable release. Players' apps update themselves to the newest release too, solatestkeeps the server in step with them. -
0.1: the newest0.1.x. -
0.1.0: exactly that release. Pre-releases (0.2.0-beta.1) only get their exact tag, neverlatestor0.2.
The server and the players' apps must speak the same protocol. A player on a different one is turned away with a message telling them who needs to update. After a release that changed the protocol, update the server when your players update.
docker stop (and so an update) stops the server cleanly within a second or two. Connected apps try to reconnect by themselves for about 40 seconds, so a quick update is usually just a hiccup. After that, players press Join again.
The room (rules, shared world flags, items, story flags, wallet and bags) lives in the server's memory. A restart or update empties it. Every player's own game still has its progress, so when players reconnect, the owner's game seeds the room again and the others' merge in. What doesn't come back: rule changes (the room starts again from the WWO_SHARED_* settings), the shared wallet and bag totals (they restart from the owner's game), and items the owner had removed from the room if another player still has them. Saving the room to a volume so it survives restarts is planned.
Every release also has two server zips:
-
WW-Online-Server-<version>-portable.zip: runs anywhere with the ASP.NET Core 9 runtime:dotnet WWOnline.Server.dll. -
WW-Online-Server-<version>-win-x64.zip: Windows, no .NET needed:WWOnline.Server.exe.
They take the same environment variables and flags. Use a service manager (systemd, NSSM) to keep them running. It sends SIGTERM / Ctrl+C for a clean stop.
From the repo root (the Dockerfile is WWOnline.Server/Dockerfile; the build context is the whole repo, and .dockerignore keeps everything but the server, the shared project and the build props out):
docker build -f WWOnline.Server/Dockerfile -t ww-online-server --build-arg VERSION=0.7.0 --build-arg COMMIT=$(git rev-parse HEAD) .
docker run -d --name ww-online -p 6969:6969 ww-online-server
Both build args are optional: without VERSION the image reports Directory.Build.props's dev version. The build stage compiles once on the build machine's own platform (the output is platform-neutral .NET), so a multi-arch build (docker buildx build --platform linux/amd64,linux/arm64 ...) doesn't run the SDK under emulation. The runtime image is mcr.microsoft.com/dotnet/aspnet:9.0-alpine and runs as the non-root app user (UID 1654). It has a shell for debugging: docker exec -it ww-online sh.