Run a tunnel server
Stand up the frp server your instances tunnel through.
Basalt tunnels work by having each instance dial outbound to a tunnel server, which accepts players on a public port and forwards them back. Basalt does not host that server for you: you run it, on a machine you control, which is what makes tunneling work without paying anyone or handing a third party your traffic.
You only need one, no matter how many nodes or instances you have. It is a small, stateless service: the whole thing is one container and a config file.
Do you need this?
Only if your game nodes aren't publicly reachable. If you control the router and can forward ports, or your node already has a public IP, players can connect directly and you can skip this entirely.
What the machine needs
- A public IP. This is the one piece of your setup that has to be reachable from the internet.
- Docker with the Compose plugin.
- Very little else. It forwards packets; it doesn't run game servers.
A small VPS is the usual choice. It does not need to be near your nodes, but latency between players and this machine is added to their connection, so closer to your players is better.
1. Fetch the bundle
curl -L https://github.com/basalt-host/basalt/archive/refs/heads/main.tar.gz \
| tar xz --strip-components=3 "basalt-main/deploy/tunnel-server"
cp .env.example .env2. Fill in .env
Three things matter:
# Generate with: openssl rand -hex 32
FRPS_TOKEN=
# The public port range players will connect on. One port per tunnel.
FRPS_PORT_MIN=30000
FRPS_PORT_MAX=30100
# Generate with: openssl rand -base64 24
FRPS_DASHBOARD_PASSWORD=The range is your ceiling on simultaneous tunnels: one port is held for the life of each tunnel. Size it for the instances you expect to tunnel plus headroom. Avoid making it enormous "just in case": Docker publishes each port in a range with its own proxy process, so a range of tens of thousands makes startup slow and memory-hungry. A few hundred is unremarkable. If you genuinely need a huge range, the bundle's README explains how to switch the service to host networking instead.
Keep the range clear of the control port (7000), the dashboard (7500), and anything else already listening on the machine.
3. Start it
docker compose up -d
docker compose logs frpsYou want to see frps started successfully.
Open the right ports, and only those
Your firewall or cloud security group needs to allow the control port from
your nodes, and the player range from anywhere, on both TCP and UDP. The
dashboard is deliberately published on loopback only; reach it with
ssh -L 7500:127.0.0.1:7500 <the machine> rather than opening it up.
4. Point Basalt at it
In the panel, Settings → Tunneling:
| Field | Value |
|---|---|
| frps server address | This machine's public IP or hostname |
| frps server port | FRPS_BIND_PORT (7000 by default) |
| frp token | FRPS_TOKEN, byte for byte |
| Remote port min / max | FRPS_PORT_MIN / FRPS_PORT_MAX, exactly |
The token and the range are the only coupling between the two sides. A token mismatch shows up in the panel as a rejected token; a range wider in the panel than on the server shows up as tunnels that allocate a port and then never register.
Keeping it updated
The frp version is pinned in .env on purpose, so an unrelated docker compose pull never
changes your tunnel control plane underneath you. Before bumping it, read the release notes and
the security advisories:
Subscribe to that advisory feed. This is an internet-facing service holding a credential that grants proxy creation across your whole fleet.
docker compose pull
docker compose up -dTunnels drop while it restarts and re-register on their own within a few seconds. Basalt reports the gap and recovers without intervention.