~/blog/networking/netbird-proxy-relayed-docker-hairpin.md

My NetBird proxy relayed everything because of a Docker hairpin

// tl;dr

My self-hosted NetBird proxy in Docker relayed everything. Its WireGuard port was random, and STUN on the same host handed back the Docker bridge address. On v0.82.0, pinning NB_PROXY_WG_PORT and setting NB_PROXY_EXTERNAL_IP_MAP to the public IP got it direct.

A packet bouncing off a mirror inside a box instead of leaving through the open window

My self-hosted NetBird reverse proxy was relaying everything. A media server I publish through it streamed fine, but every packet between the proxy and that server's peer went through NetBird's relay instead of a direct WireGuard path.

What the relay was costing

The setup is one small public VPS running the official self-hosted NetBird Docker stack. That's Traefik, the combined netbird-server (management, signal, relay and embedded STUN on 3478/udp), the dashboard, and NetBird's reverse proxy component, which publishes self-hosted services to the internet.

With two remote streams going through the relay, the VPS sat at about 27% CPU. After the fix, with one remote stream of about 8.7 Mbps going direct, it was about 4%. netbird-server dropped under 1% because the relay had nothing to do.

Not the same load, so not a clean benchmark. The relay going idle is the part I trust.

There turned out to be two separate problems.

A random WireGuard port

The proxy's embedded NetBird client was running with wg_port: 0, which means a random port. Compose was publishing 51820/udp. So even a perfect candidate would have pointed peers at a port nobody was listening on.

Pin it in the proxy's env file:

NB_PROXY_WG_PORT=51820

The release notes call the setting --wg-port. The env name above follows the same NB_PROXY_ pattern and is what worked for me.

That alone didn't get me off the relay.

STUN on the same Docker host lies

The proxy asks a STUN server what its public address is. In my case that STUN server was the embedded one in netbird-server, on the same Docker host. The request never left the box. The answer came back as the Docker bridge gateway address, 172.x.0.1, instead of 203.0.113.10.

So the proxy never had a public (server-reflexive) candidate to offer. It asked its own house for its street address and got the room number.

An external STUN server, queried from the same container, reported the public IP correctly, port preserved. That confirmed the hairpin.

The coturn detour

My first fix that worked was a STUN-only coturn on a second, separate public host. I firewalled it so only the VPS's address could reach it, and added it to the server's STUN list. Coturn here was only a STUN workaround, not NetBird's relay. The docs treat coturn as part of the older multi-container layout anyway.

Two traps on the way. In my setup, setting server.stuns in the combined server's config turned the embedded STUN off completely. The log said so:

STUN: disabled

The advertised list became exactly what I put in server.stuns. I couldn't have embedded plus external.

Second, the coturn 4.18 container crash-looped when I passed --no-dtls. Dropping the flag fixed it.

Mapping the public address in v0.82.0

NetBird v0.82.0 (29 Oct 2026) added NB_PROXY_EXTERNAL_IP_MAP for the proxy's embedded client, the env form of --external-ip-map. The release notes say a proxy "in a container or behind NAT can advertise its public address and peers reach it directly instead of over relay." They also say it needs a fixed, forwarded --wg-port, and that it applies to single-account deployments.

The format that worked for me is <public-ip>/<container-ip>:

NB_PROXY_WG_PORT=51820
NB_PROXY_EXTERNAL_IP_MAP=203.0.113.10/172.x.0.40

Recreate only the proxy. The compose service is named proxy, the container netbird-proxy:

$ docker compose up -d --no-deps proxy
$ docker logs netbird-proxy 2>&1 | grep -o 'nat_external_ips: .*'
nat_external_ips: [...]

I retired the coturn workaround the same day. The embedded STUN is the only one again, every client shows one STUN server as Available, and the proxy is P2P to every peer it talks to.

IPv6 takes a second, comma-separated element:

NB_PROXY_EXTERNAL_IP_MAP=203.0.113.10/172.x.0.40,2001:db8::10/fdxx:x::40

Before that, the proxy only advertised its useless ULA address. IPv6 in Docker is set per network (enable_ipv6: true on the compose network), not in daemon.json, which is where I looked first. Once it was in, an IPv6-capable peer switched to a direct v6 path.

One more trap: in my setup, if one element of the map fails to parse, NetBird drops the whole map. Re-check the candidates after every edit.

Proving it actually went direct

netbird status -d shows only the candidate pair ICE selected. Mine labelled it prflx before and after the fix, so it told me nothing.

The client debug log is better. Raise the level on a peer:

netbird debug log level debug

Restart the proxy, then look for the candidates it sent:

$ grep OnRemoteCandidate <client-log> | grep <proxy-key> | grep -o 'udp4 host [^ ]*'
udp4 host 203.0.113.10:51820

That candidate is generated locally from the map. No STUN involved. A junk candidate with the bridge address still shows up from the hairpin STUN. It's harmless.

Set the level back when you're done:

netbird debug log level info

The docs say a daemon restart resets it too. netbird down and netbird up don't reset it.

Side effect of all the restarting: every proxy restart registers a new ephemeral proxy peer. Check for orphans afterwards and delete them through the API. In my setup a GET on a proxy-owned peer returned 422, but DELETE worked.

How sure I am

Observed: the relay going idle, the CPU drop, the bridge address in the hairpin STUN answer, the public candidate appearing in the debug log once the map was set, and the v6 peer going direct.

Documented: the env variable, the fixed-port requirement and the single-account limit, all from the v0.82.0 release notes. The map format, the parse behaviour, the server.stuns behaviour and the 422 on proxy peers are only what I saw on 0.82.0 in October 2026.

netbird: 0.82.0 (proxy and server)
netbird-server: combined container, config.yaml
reverse proxy: Traefik (official self-hosted stack)
coturn: 4.18 (temporary STUN-only workaround, retired)
checked: 2026-10-31

Names and addresses are changed. Everything else happened.

$ ./ask --help

Stuck on something similar? Ask below and I'll try to help you work through it. It helps to include:

  • --running hardware, OS, versions
  • --tried what you've already tried
  • --error the exact message, word for word

Comments are public, so leave out passwords, keys and public IP addresses.