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.
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.
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.
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.
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.
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.
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.
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.
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:
frpc.toml on the game host. The client-side proxy definition. Local IP and
port, remote port, protocol.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
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
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.
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.
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.