← Back to Wiki
Networking / Game Servers

Expose a Self-Hosted Game Server to the Internet

Running a game server at home is easy. Letting friends connect from outside your network without exposing your home IP or fighting your ISP's NAT is the actual hard part. Here are two approaches that cover almost every case, and a subtle bug that can make a tunnel look perfectly healthy while being completely broken.

Approach 1: single-port tunnels (playit.gg, ngrok, similar)

For the vast majority of game servers — anything that listens on one TCP or UDP port — a tunneling service like playit.gg is the simplest option. Install a small agent on the same machine as the game server, claim a tunnel through their dashboard, and you get a public address that forwards straight to your local port. No port forwarding, no static IP needed, free tier covers most personal use.

Client gotcha worth documenting for your players: some game clients (Minecraft Bedrock, Hytale) have separate address and port fields. Pasting a combined host:port string into a single field throws a generic, unhelpful "not correctly formatted" error that has nothing to do with the server itself.
Rate limits are real and shared across your account. Setting up several tunnels back-to-back in a short window can trip an account-wide rate limit that also affects your existing tunnels — if an already-working server suddenly starts throwing connection errors right after you set up a new one, check whether you just tripped a rate limit rather than assuming something broke.

Approach 2: a VPS running frp (for games needing a port range)

Some games need far more than one port — dozens of UDP ports for game-world sharding, a separate port for an internal message queue, etc. That doesn't fit the single-port tunnel model any tunneling service offers. For this, a cheap VPS running frp (Fast Reverse Proxy) works well — you get full control over exactly which ports are exposed.

Only expose what actually needs to be public. Admin UIs, database ports, and internal message-queue management ports should never be tunneled — only the actual game-client ports and whatever single port a companion service genuinely needs from the outside.

Containerized game servers: the network namespace has to match

If the game runs inside Docker on its own custom bridge network (common when a game ships with a sidecar container, or you've put it behind its own internal network for isolation), running the tunnel client directly on the host with localIP = 127.0.0.1 won't reach it — the game process is only reachable via the container's own IP or Docker's internal DNS, from inside that network. The fix is to run the tunnel client itself as another container joined to the same Docker network, and point it at the game container by name (Docker's embedded DNS resolves container names automatically) instead of an IP:

frpc:
  image: alpine:latest
  container_name: frpc
  restart: unless-stopped
  networks:
    - gamenet
  volumes:
    - /opt/frp:/opt/frp
  entrypoint: ["/opt/frp/frpc", "-c", "/opt/frp/frpc.toml"]

Then in frpc.toml, set localIP to the game container's service name (e.g. "enshrouded") instead of an IP address — it resolves correctly because both containers share the same Docker network.

Split-horizon DNS for LAN players

If people on your own network also want to connect, route them directly to the local server instead of round-tripping out through the internet and back in through the tunnel. Set up split-horizon DNS: public resolvers get the VPS's IP, but your local router/DNS returns the game server's LAN IP for the same hostname. Verify both directions actually resolve differently before trusting it.

Gotcha: split-horizon DNS only overrides the IP — not the port. This matters a lot for Minecraft Java's SRV-record convention, where a client resolves the hostname to a port via a separate SRV lookup, then connects to whatever IP the A record resolves to on that port. If your game's real local port differs from the port you've advertised externally (say, a Docker host-port remap put the real port at 25566 while the public SRV record still advertises 25565), a LAN client following the split-horizon override gets sent to the right IP but the wrong port — nothing is listening there locally, only the original external port is reachable, and only via the tunnel. WAN clients work fine throughout (they go through the tunnel, which has the correct local target), making this a LAN-only failure that's easy to misdiagnose as a DNS problem when it's actually a port mismatch. Fix: make the externally-advertised port equal to the real local port, eliminating the remap entirely, rather than trying to make DNS smart enough to also swap ports (it can't).

The bug that will fool you: "registered successfully" isn't the same as "working"

A tunnel proxy registering successfully with the tunnel server only proves the control-plane handshake worked — it says nothing about whether the tunnel's local dial target is actually correct. One real case: every single proxy for a multi-port game registered fine from day one, but nobody could actually connect for over a day, because the tunnel client's config had every proxy's localIP set to 127.0.0.1 — while the actual game server process bound to the machine's real LAN IP, not localhost. One unrelated companion service happened to be configured correctly (bound to localhost for real), which is exactly why that one tested fine and gave false confidence the whole tunnel was healthy.

Lesson: don't treat "proxy registered" as proof of connectivity. Before trusting a tunnel is actually working, check what the service you're tunneling to is actually bound to (netstat -ulnp / ss -ulnp), and confirm your tunnel client's local target actually matches that — don't just assume 127.0.0.1 because it's the common default. Ultimately, verify with a real client connection from outside your network, not just clean-looking logs.

A new port needs to clear three independent gates, not one

Getting a new port working through a VPS+frp setup means checking (and opening) the same thing in three separate places — missing any one of them looks identical from the client's side ("proxy registered, still can't connect"), so check them in this order:

  1. frpc.toml on the game host — the client-side proxy definition (local IP/port, remote port, protocol).
  2. frps.toml's allowPorts allowlist on the VPS — frp's server rejects any proxy for a remote port not explicitly listed here, even though the client's control connection (auth, login) succeeds fine. The failure only shows up per-proxy:
    new proxy [myserver] type [udp] error: acquire port 25566 error: port not allowed
  3. The VPS's own host firewall (ufw, or equivalent) — a completely separate layer from frp's own allowlist. Even with the first two correct, a default-deny firewall with no explicit rule for the new port will silently drop every packet before it ever reaches frps. This one is the sneakiest, because frps's own logs show the proxy registering successfully — the firewall operates one layer below frp entirely, so frp has no idea traffic is being dropped:
    ufw allow 25566/tcp comment 'my game server'
    Confirm a port is actually reachable end-to-end with a raw connection test from outside, not just a registration log line:
    timeout 5 bash -c "echo > /dev/tcp/<vps-ip>/25566" && echo OPEN || echo CLOSED

A related gotcha: config that's only read once at startup

If a service's IP-advertisement config is only read once, at its own process startup, and baked into a lower-level networking flag from there, restarting a higher-level component (like the game server process itself) without restarting the lower-level service underneath it can leave a stale advertised IP in place indefinitely — even after you've correctly updated the config file. If a server's advertised connection details seem stuck on an old value despite a config change and a restart, check whether there's a lower layer that needs its own explicit restart too.

Testing from your own home network proves less than it feels like it does

A "works!" report tested from inside your own network can be a false positive. If you've set up split-horizon DNS (as recommended above), every device on your LAN resolves the public hostname straight to the local IP — completely bypassing the tunnel. Connecting successfully from home only proves the game server itself is up; it says nothing about whether the actual public tunnel path works. This gets genuinely risky if you decommission a fallback (an old tunnel, a previous hosting method) on the strength of an unconfirmed "it works" report — you can end up with zero verified public path and not know it until an actual outside player tries to connect.

Fix: only trust a "confirmed working" report if it was tested from genuinely outside your LAN — cellular data, a VPN, or asking someone off-network to try. Do this before decommissioning any old tunnel or fallback path, not after.

Once you're confident, actually remove the old tunnel — don't just disable it

It's reasonable to leave an old tunneling agent stopped-but-installed as a fallback while you validate a replacement. Once that replacement has been confirmed working (from genuinely outside the LAN, per above) for long enough that you trust it, go back and actually uninstall the old agent rather than leaving it disabled indefinitely — a disabled-but-present agent is one stray systemctl enable or container restart away from silently coming back and fighting with your new setup for the same local port.

A plain package-manager purge usually isn't complete. On at least one tunneling agent, the package manager's own uninstall left the agent's config/state directory, its dedicated service account, and an apt-style repo entry + signing key it had added on install all behind. If you added a third-party repo to install something, remember to remove that repo entry too — not just the package.