> ## Content Index
> Fetch the complete content index at: https://blog.coopnet.us/llms.txt
> Use this file to discover other available public pages before exploring further.

# My NetBird proxy relayed everything because of a Docker hairpin
- URL: https://blog.coopnet.us/netbird-proxy-relayed-docker-hairpin/
- Published: 2026-11-01T05:44:43.000Z
- Updated: 2026-11-01T05:44:43.000Z
- Description: 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.
- Author: Renstur
- Tags: networking, netbird, stun, docker

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:

```ini
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:

```text
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>`:

```ini
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`:

```console
$ 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:

```ini
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:

```sh
netbird debug log level debug

```

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

```console
$ 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:

```sh
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.

```tested-on
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.