Putting HAProxy in front of a game node is the standard way to hide origin IPs and absorb floods, and it works until you look at the server log and every player connects from 10.0.0.14. That single line breaks per-IP bans, connection rate limits, geo rules, and any abuse forensics you had. The cause is structural: a TCP proxy terminates the client connection and opens a new one to the backend, so the backend’s view of the peer is the proxy, not the player. PROXY protocol exists to fix exactly that, but only if both ends speak it.
Why Plain TCP Forwarding Destroys Source IPs
HAProxy in mode tcp is a bidirectional byte pump. It accepts the client’s socket, establishes a separate socket to the backend, and copies bytes. The backend sees the proxy’s source address and a new ephemeral port. There is no field in a raw TCP stream to carry the original address, and no amount of header inspection will recover it after the fact.
The usual workarounds all fail for game traffic. Reading X-Forwarded-For does not apply — game protocols are binary UDP or TCP, not HTTP. Trusting the proxy to rewrite connection metadata works only for protocols with in-band identity, which most game handshakes do not have. Logging a session identifier and joining it with HAProxy logs is possible but gives you the mapping minutes late and not at all for UDP.
PROXY Protocol: v1 and v2
PROXY protocol prepends a single header to the connection before any game bytes. The backend parses it, learns the real source, then continues reading the normal stream. Two versions exist, and the choice matters operationally.
| Aspect | v1 | v2 |
|---|---|---|
| Format | Text line | Binary, 16-byte signature |
| Signature | PROXY TCP4 ... |
0D 0A 0D 0A 00 0D 0A 51 55 49 54 0A |
| Address families | TCP4 / TCP6 only | TCP/UDP, IPv4/IPv6, UNIX |
| UDP support | No | Yes (with datagram framing) |
| TLV extensions | No | Yes |
| Overhead | ~40-80 bytes | 16 + 4 + length |
Game traffic is UDP in almost every modern title, so v2 is the version you need. v1 is fine for a Minecraft TCP front-end and is easier to debug because you can see it in a hex dump as plain text.
# HAProxy front-end sending PROXY v2 to the backend
frontend game_in
bind *:25565
mode tcp
default_backend mc_nodes
backend mc_nodes
mode tcp
server node1 10.0.0.14:25566 send-proxy-v2 check
server node2 10.0.0.15:25566 send-proxy-v2 check backup
One rule that catches everyone: the backend listener must either expect PROXY headers on every connection or on none. Mixing causes the backend to interpret a PROXY header as game data, and you get protocol errors or a parser crash on the first packet.
Which Game Servers Read It Natively
Native support is thinner than the web world, where every reverse proxy speaks it.
- Minecraft Java — supported on modern Paper/Purpur builds via
proxy-protocol: trueinconfig/paper-global.yml. Vanilla does not support it; vanilla expects a Velocity/BungeeCord handshake instead. - Velocity and BungeeCord — these are the proxy layer themselves, so they do not need PROXY protocol; they forward identity in their own handshake. Use PROXY protocol only between HAProxy and an edge Velocity node.
- Source-engine servers (CS2, TF2, Rust via Steam networking) — no native PROXY protocol. Identity comes from Steam auth tickets, and per-IP logic must come from the game’s own logs or a plugin.
- ARK, Valheim, Palworld — no native support; UDP backends need the proxy to inject the header and the server to be patched, which most do not allow.
- Rust (Oxide/uMod) — plugins can read a forwarded address from connection metadata, but the vanilla binary will not.
Where a server cannot read the header, the pragmatic options are a plugin that accepts it, a mod that trusts a shared secret plus an address header on the game port, or accepting that per-IP logic has to run at the proxy layer instead of the game layer.
Preserving Real IP Behind Minecraft-Style Proxies
The common topology has three hops: player to HAProxy, HAProxy to Velocity, Velocity to Paper. PROXY protocol only solves the first hop. Velocity passes the player’s address to Paper inside its own login handshake, so as long as Velocity is configured to trust the HAProxy-supplied address, the chain holds end to end.
# Paper: accept PROXY header from the front-end
proxy-protocol: true
velocity:
enabled: true
online-mode: true
secret: "<32-char-shared-secret>"
# Velocity: only trust forwarded addresses from the HAProxy IPs
# velocity.toml
[advanced]
haproxy-protocol = true
# and in the firewall, allow the Velocity port only from HAProxy
The security condition is non-negotiable: the Velocity port must be firewalled to HAProxy’s addresses only. An exposed Velocity port with haproxy-protocol enabled lets anyone forge a source address and bypass IP bans in a single packet. The same applies to Paper’s port — bind it to the private network and let nothing else reach it.
UDP Game Nodes and the v2 Header
For UDP, the model changes. HAProxy’s UDP support can prepend a v2 header to each datagram, but many game servers cannot parse it and will simply discard the packet. In practice the UDP front-end is more often used for filtering and rate limiting than for IP recovery. If you need per-player IPs on a UDP title, the reliable path is session tracking at the proxy: log client_ip:client_port -> backend_ip:backend_port with a session table, and join those records to your game logs by timestamp and port.
Troubleshooting: When the IP Is Still Wrong
- Header sent, backend ignores it. Check that the backend build actually has PROXY support compiled in — the option is often off by default and fails silently, logging the proxy’s IP as the peer.
- Header sent twice. Two proxies in series each prepending a header. Only the last hop before a header-aware backend should send one, or use
accept-proxyon the intermediary. - All players show the same IP.
send-proxymissing from the server line, or the wrong backend selected by a health check. - Truncated v2 header. Some middleboxes or a MTU issue split the first packet. Verify with a capture on the backend interface.
- v1 mixed with v2. A single listener configured for v2 fed by a v1 sender produces a signature mismatch and an immediate close.
# Capture on the backend to confirm the header is present
tcpdump -i eth1 -X -c 5 'tcp port 25566'
# v2 starts with the 12-byte signature: 0d0a0d0a000d0a515549540a
# v1 looks like: PROXY TCP4 203.0.113.9 10.0.0.14 51234 25566
# HAProxy stats: confirm the right backend and check retries
echo "show stat" | socat stdio /var/run/haproxy.sock | cut -d, -f1,2,18,19
If the capture shows the header and the backend still logs the proxy address, the problem is the backend configuration, not the network. If the capture shows no header, the HAProxy server line is missing send-proxy-v2, or the backend is reached through a different path than the one you are capturing.
Design Rules That Keep It Working
Terminate PROXY protocol as close to the game process as the software allows. Keep every header-aware listener on a private network with a firewall allowlist. Log both the proxy’s observation and the backend’s parsed address for a week after any change, and diff them — a silent fallback to the proxy IP is the most common regression. Treat the proxy as a trust boundary: rate limits, geo rules, and bans belong at HAProxy where the real address is always visible.
For nodes at the origin side of this topology, bare metal game server hosting gives you the private-network layout and static addresses this design assumes. For sizing the proxy tier itself, see the game server hosting reviews and comparisons — HAProxy needs very little CPU but a surprising amount of conntrack table for a busy join port.

Leave a Reply
You must be logged in to post a comment.