DEV Community

angelpackyard
angelpackyard

Posted on Edited on Originally published at packyard.ca

Permanent Minecraft server addresses with a WireGuard edge and mc-router

Originally published at packyard.ca

The problem

On most game servers, the address players save is the machine's own IP and port. That works until the server has to move: the machine is being retired, it's full, or the server needs a bigger one. Then the address changes, and everyone who saved it, plus every server list, Discord pin and bookmark, points at nothing.

We wanted the address to belong to the server, not the machine. Then a server can move between machines and nobody has to update anything.

The setup

Players never connect to the machine their server runs on. They connect to one edge: a small OVH VPS in Hillsboro, Oregon. The game servers run on larger shared-vCPU VPSes at Contabo in Seattle. Each node has a WireGuard tunnel to the edge. Round trip from edge to node is 5.3 / 5.6 / 5.8 ms (min/avg/max, 20 pings).

player ── TCP 25565 ──▶ edge (OVH Hillsboro)
                         mc-router: read hostname from the handshake
                         └── WireGuard ──▶ node (Contabo Seattle)
                                            └── server container, bound to the node's tunnel address only
Enter fullscreen mode Exit fullscreen mode

Minecraft Java makes this easy. The first packet a client sends, the handshake, contains the hostname the player typed. mc-router reads that hostname and proxies the TCP connection to whatever backend it maps to. So every server gets a name like yourserver.play.packyard.ca on the default port, one wildcard DNS record (*.play.packyard.ca) points at the edge, and there is no per-server DNS or SRV record to manage. A hostname with no route gets its connection closed.

Our orchestrator owns the mapping and pushes it to mc-router's HTTP API, which listens only on the edge's localhost:

POST /routes
{"serverAddress": "yourserver.play.packyard.ca", "backend": "<node tunnel address>:<port>"}
Enter fullscreen mode Exit fullscreen mode

Moving a server means changing that backend. The name players saved doesn't change.

On the node, game ports are published by Docker on the WireGuard address only. That matters because Docker's published ports skip ufw: if a container port is published on all interfaces, the firewall doesn't save you. The node's firewall takes WireGuard and SSH from a short allowlist and nothing else.

Valheim, Terraria and Project Zomboid are harder, because their protocols carry no hostname, so there is nothing to route on. Each of those servers gets its own port on the edge instead, and nftables on the edge DNATs it over WireGuard to the same port on the node. Ports come from one pool for the whole fleet, not per node, so a server keeps its port when it moves. The address is name.games.packyard.ca:port. That's a separate wildcard, so UDP games can move to a different edge IP later without anyone's address changing.

Two things came with that:

  • We SNAT forwarded traffic on the edge so replies go back through the tunnel. The cost is that game servers see every player as the edge's tunnel address, so IP bans don't work in Terraria. Valheim and Zomboid ban by SteamID, so they're fine.

  • Valheim and Zomboid register with Steam from whatever IP their outbound traffic leaves from. Out of the box that would have been the node. So the node policy-routes the container subnet into the WireGuard tunnel, and the edge NATs it out. Steam's server lookup now lists our servers at the edge's IP and none at the node's.
    What this does and doesn't cover: all game traffic and Steam listings go through the edge. The control panel (Pelican) talks to its daemon on each node through an outbound Cloudflare Tunnel, so the browser console doesn't connect to the node either. SFTP goes through the edge too: you connect to the edge, never to the node. None of that makes a node's IP secret. It's in our provider's records, and the node still makes some outbound connections of its own, like OS updates. What we can say is that nothing we hand to a customer contains it.

What broke

  • The first edge was in the wrong place. We started with an edge VPS in Singapore, in front of nodes in Seattle: 215 ms from edge to node, added to every player's ping. We replaced it with the Hillsboro box (5 to 6 ms). Moving it took two setup scripts and one DNS change (60 s TTL), and no player address changed. That was the first real test of the whole idea, on the edge instead of a node. Lesson: the extra hop is only cheap when the edge sits next to the nodes.

  • mc-router forgot its routes on restart. After one mc-router restart, the routes we had pushed came back about 1 minute 50 seconds later. They came back because the orchestrator re-pushes every route every two minutes, not because the router kept them. mc-router can load routes from a JSON file, but in our setup the routes we add through the API don't end up in that file. So we treat the router as a cache, and the orchestrator is the source of truth. Today, an edge restart means up to two minutes of closed connections. An immediate re-push when the router comes back would cut that.

  • Valheim needed the egress change above. With Steam registration turned off, the server never answered the Steam query that the client's "Join IP" relies on, so nobody could join. With it on, it registered the node's IP. Routing container egress through the tunnel fixed both.

  • Failed installs looked successful. The version of Pelican's node daemon we run ignores the exit code of an install script, so a modpack whose download failed showed up in the panel as installed. Every install script we ship now writes a marker file as its last step. If the marker is missing, the orchestrator treats the install as failed, reinstalls up to twice, and then alerts us.

  • Moving a server between nodes is the part we haven't done yet. The address side is ready: a move is one route change at the edge. The Pelican version we run (1.0.0-rc.1) has an API endpoint to transfer a server to another node, but we haven't done a transfer yet, so we can't tell you how it behaves with a big modpack world.

The tradeoffs

  • Shared CPU. The nodes are shared-vCPU VPSes, which is how the price stays low. To make up for it, we never sell more RAM than a node has: when a node is full, those sizes show as sold out rather than squeezing in one more server. We watch tick rate (TPS) on real servers.

  • One region. Everything is in US West for now. Players on the East Coast or further away get more latency than they would from a closer host.

  • The edge is one more hop, and one more thing that can break. If it goes down, every server behind it is unreachable even though the servers keep running, and their outbound internet stops too. Recovery is a new edge from the same scripts plus a DNS change.

  • DDoS. The edge is an OVH VPS, so it gets OVH's standard always-on network DDoS mitigation. That isn't a game-specific filter, and because everything shares the edge, an attack that gets through takes every server on it offline. A game-aware proxy in front of the edge is the next step.

Closing

This runs Packyard, a small game host at $1.50 per GB of RAM a month ($5 minimum): packyard.ca

Example configs: https://github.com/Himtome1/permanent-address-edge

Update 27 October 2026: customer SFTP is live, routed through the edge.

Update 31 October 2026: we added a second node on 27 October. No server has moved between them yet.

Top comments (0)