SeriesRebuilding Remote AccessPart 3 of 5

The gateway: self-hosted NetBird behind Caddy, with a relay

Now the WireGuard control plane. This part stands up self-hosted NetBird on a DMZ gateway, fronts it with Caddy issuing TLS over DNS-01, points it at the SSO for auth, and adds a coturn relay for the peers that can't connect directly.

Real client work, anonymized. Generic domains and IPs throughout.

The identity spine can now answer “who are you?” with a second factor. This part builds the thing that asks the question, the WireGuard control plane. We’re self-hosting NetBird, which means running management, signal, and the dashboard ourselves on a gateway VM in the DMZ, behind Caddy for TLS, with every peer authenticated through the Keycloak from Part 2.

Where I first met NetBird

I’d used NetBird before in a personal capacity, including wiring secure remote access into my own HPC homelab, where it was the front door to a login node (that’s a separate series, still in progress). But that was one peer joining someone else’s control plane. This is the other half of the tool: running the whole control plane yourself, as a client’s primary access layer. Same software, very different responsibility. It’s a good example of a lab teaching you something you later do for real.

The mental model: a control plane, not a concentrator

OpenVPN is a concentrator. Every packet flows through one server. WireGuard by way of NetBird is a mesh, where peers try to talk directly and the self-hosted services mostly broker the introductions. There are four pieces:

  • management is the brain. It knows who’s enrolled, what groups they’re in, and which routes and policies apply.
  • signal helps two peers find each other and punch a direct tunnel through NAT.
  • dashboard is the web UI, and it’s an OIDC client of the Keycloak from Part 2.
  • coturn is the relay, the fallback for peers that can’t punch a direct path. Their traffic relays through it instead of failing.

Why self-host instead of using the SaaS

The hosted offering is genuinely good. The client self-hosts because the directory, the policies, and the audit trail are things they want to own and keep on-prem, and because the same gateway already sits in their DMZ. Self-hosting also means identity (Keycloak into the directory) is the only place accounts live. Nothing about who works there leaves the building.

TLS first: Caddy with DNS-01

Everything public-facing terminates TLS at Caddy on the gateway. Here’s the wrinkle. These services sit in the DMZ, and I didn’t want to open inbound HTTP just to satisfy ACME’s HTTP challenge. So Caddy uses the DNS-01 challenge instead. It proves domain control by writing a DNS record through the provider’s API, and never needs port 80 reachable from outside.

# gateway VM, Caddyfile (global ACME via DNS-01; staging first, then prod)
{
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
login.example.com  { reverse_proxy idp.int.example.com:8080 }   # Keycloak
mesh.example.com   { reverse_proxy localhost:80 }               # NetBird dashboard (+ /api, /signal blocks)
connect.example.com { root * /srv/landing; file_server }        # the user landing page

Why DNS-01, and why staging first

DNS-01 can issue certs, wildcards included, without any inbound HTTP, which is exactly what you want for DMZ services behind a firewall. I also point ACME at Let’s Encrypt staging until the whole chain works, because the production CA has rate limits you can burn through fast while you’re still debugging. Flip to production only once the staging certs issue cleanly.

curl -I https://login.example.com

After I flipped ACME from staging to the production CA, that came back from Caddy with a valid certificate chain, with TLS terminating at the gateway for a service running plain HTTP behind it.

OIDC clients: teaching NetBird to use the SSO

NetBird doesn’t store passwords. It delegates to Keycloak. That takes two OIDC clients in the realm: a public PKCE client for the dashboard, and a device-authorization client for logging in on machines without a browser.

Why a device-flow client for the CLI

When you run the NetBird client on a headless box or a phone, there’s no tidy browser redirect to lean on. The OAuth 2.0 Device Authorization Grant is the “go to this URL and type this code” flow. The user authenticates, with MFA, on a device that does have a browser, and the client picks up its token. It’s the same pattern a smart TV uses to log you in.

Record the issuer (https://login.example.com/realms/<realm>) and the client IDs, then feed them into NetBird’s management.json and the dashboard environment so every enrollment authenticates through Keycloak.

NetBird and coturn, and opening exactly three ports

The services come up with compose, and coturn gets the realm, a static auth secret, and a bounded relay port range.

docker compose up -d        # management, signal, dashboard, coturn
docker compose ps           # all healthy, logs clean

docker compose ps showed management, signal, dashboard, and coturn all up and healthy with clean logs. The control plane was live.

After that, the only inbound the edge firewall forwards to the gateway is 443/tcp (dashboard, API, and TLS), 3478/udp for coturn and STUN, and the relay UDP range. The existing OpenVPN rule on 1194/udp stays exactly where it is, because the whole approach is additive.

Bound the relay range, and leave the old rule alone

coturn needs a range of UDP ports for relayed traffic. Define a small, explicit window (say 100 ports) rather than opening a wide swath, and forward exactly that. And don’t touch the OpenVPN forward. The entire migration depends on it staying live as the fallback.

What’s next

The control plane is up, TLS is valid, and peers can authenticate through the SSO with a second factor. But an authenticated peer that can’t reach anything internal is useless. Part 4 turns the gateway into a routing peer, advertises the internal subnets, pushes internal DNS, and writes the default-deny, group-based access policies that decide who can reach what.