← Back to Wiki
Networking / Game Servers

Expose a Self-Hosted Game Server Without Port Forwarding (frp Tunnel)

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.

Share on X

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

For the vast majority of game servers, anything listening 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. You get a public address forwarding straight to your local port. No port forwarding, no static IP, and the free tier covers most personal use.

A client gotcha worth documenting for your players. Some game clients have separate address and port fields. Minecraft Bedrock and Hytale both do. Paste a combined host:port string into a single field and you get a generic, unhelpful "not correctly formatted" error that has nothing to do with the server.
BE WARNED: rate limits are real and shared across your account. Set up several tunnels back-to-back in a short window and you trip an account-wide rate limit that also hits your existing tunnels. If an already-working server starts throwing connection errors right after you set up a new one, check whether you just tripped a rate limit before 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. That does not fit the single-port tunnel model any tunneling service offers. For those, run frp, Fast Reverse Proxy, on a cheap VPS. You get full control over exactly which ports are exposed.

BE WARNED: only expose what needs to be public. Never tunnel admin UIs, database ports or internal message-queue management ports. Only the game-client ports, and whatever single port a companion service genuinely needs from outside.

Containerized game servers: the network namespace has to match

If the game runs inside Docker on its own custom bridge network, running the tunnel client directly on the host with localIP = 127.0.0.1 will not reach it. That setup is common when a game ships with a sidecar container, or you put it behind its own internal network for isolation. The game process is only reachable via the container's own IP or Docker's internal DNS, from inside that network. Run the tunnel client as another container joined to the same Docker network, and point it at the game container by name rather than by IP. Docker's embedded DNS resolves container names automatically:

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, such as "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 straight 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. Your local router or DNS returns the game server's LAN IP for the same hostname. Verify both directions resolve differently before you trust it.

BE WARNED: split-horizon DNS overrides the IP only, not the port. That matters 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 gives, on that port. Say your game's real local port differs from the port you advertise externally. 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 lands on the right IP and 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, because they go through the tunnel, which has the correct local target. So it is a LAN-only failure, easy to misdiagnose as DNS when it is a port mismatch. Fix: make the externally advertised port equal the real local port and eliminate the remap. Do not try to make DNS smart enough to swap ports. It cannot.
BE WARNED: split-horizon DNS can also fail because nothing was ever listening on the host. A different failure mode from the port mismatch above. If the game and its tunnel-client sidecar share a private Docker network, per the containerized setup earlier on this page, the tunnel reaches the game fine over that internal network. That is a completely separate path from a LAN client hitting the host's own IP. If the container's docker-compose.yml never publishes the game's port to the host, with no ports: entry for that service, nothing is listening there for a LAN client, even while the public tunnel path works perfectly underneath. This hides for a long time behind DNS caching. A device with a cached public-IP answer keeps connecting through the tunnel until that cache expires. Then a fresh lookup hits the broken local path and it looks like a sudden outage with nothing having changed. Fix: publish the port to the host explicitly, with ports: ["25565:25565/udp"]. Reachability over the tunnel's internal Docker network proves nothing about whether a LAN client hitting the host IP can reach it.

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

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

Lesson: do not treat "proxy registered" as proof of connectivity. Before you trust a tunnel, check what the service you are tunneling to is bound to, with netstat -ulnp or ss -ulnp. Confirm your tunnel client's local target matches. Do not assume 127.0.0.1 because it is the common default. Then verify with a real client connection from outside your network, not clean-looking logs.

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

Getting a new port working through a VPS and frp means opening the same thing in three separate places. Miss any one and it looks identical from the client's side. "Proxy registered, still can't connect." Check them in this order:

  1. frpc.toml on the game host. The client-side proxy definition. Local IP and port, remote port, protocol.
  2. frps.toml's allowPorts allowlist on the VPS. frp's server rejects any proxy for a remote port not listed here, even though the client's control connection, auth and login all succeed 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 allowlist. Even with the first two correct, a default-deny firewall with no rule for the new port drops every packet before it reaches frps. This one is the sneakiest. frps's own logs show the proxy registering successfully, because the firewall operates a layer below frp entirely and frp has no idea traffic is being dropped:
    ufw allow 25566/tcp comment 'my game server'
    Confirm a port is reachable end to end with a raw connection test from outside, not a registration log line:
    timeout 5 bash -c "echo > /dev/tcp/<vps-ip>/25566" && echo OPEN || echo CLOSED

A tunnel that silently stops passing traffic while still showing "running"

BE WARNED: frp sizes each client's server-side work-connection buffer from a pool-count setting most setups never configure. It defaults far too small once you tunnel dozens of proxies for one multi-port game. Overflow it and you get a real, recurring bug. The tunnel client process keeps running. Its control connection to the server stays fully established. The data path silently dies. No crash, no error in the service status, just a log file that stops being written to. That looks identical to "the server is offline". A plain restart only clears the symptom. It recurs on the next mass-reconnect burst, after any restart of dozens of proxies at once, until the setting is fixed. The buffer size is min(client's configured pool count, server's configured pool ceiling) + 10. Both sides need the setting raised, not one, since the effective size is whichever is smaller. Raising only the server's ceiling while the client's count stays at its low default does nothing.

Fix: set a generous pool count explicitly on both the tunnel client and server config, for any setup tunneling more than a handful of proxies. Do not rely on the defaults. Here is how to tell this is your problem. The tunnel process shows "running" and its log file has had no new line for longer than makes sense on an active connection. Do not trust the "running" status. That is the exact signature of this bug.

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

BE WARNED: a service's IP-advertisement config may be read once, at its own process startup, then baked into a lower-level networking flag. Restart a higher-level component, like the game server process itself, without restarting the lower-level service underneath, and a stale advertised IP stays in place indefinitely. Even after you 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 a lower layer needs its own restart.

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

BE WARNED: a "works!" report tested from inside your own network can be a false positive. With split-horizon DNS set up as recommended above, every device on your LAN resolves the public hostname straight to the local IP, bypassing the tunnel completely. Connecting from home only proves the game server is up. It says nothing about the public tunnel path. That gets genuinely risky if you decommission a fallback on the strength of an unconfirmed "it works". An old tunnel, a previous hosting method. You end up with zero verified public path and no idea until an outside player tries to connect.

Fix: only trust a "confirmed working" report tested from genuinely outside your LAN. Cellular data, a VPN, or someone off-network trying it. Do that before you decommission any old tunnel or fallback path, not after.

Once you're confident, remove the old tunnel. Don't just disable it

Leaving an old tunneling agent stopped but installed is reasonable while you validate a replacement. Once that replacement is confirmed working from genuinely outside the LAN, per above, and has held long enough that you trust it, go back and uninstall the old agent. A disabled-but-present agent is one stray systemctl enable or container restart away from coming back and fighting your new setup for the same local port.

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