How to Self-Host NetBird VPN on AlmaLinux 10 With Caddy

Self-host NetBird, the WireGuard-based mesh VPN, on AlmaLinux 10 behind an existing Caddy reverse proxy, with an embedded IdP, access control and peer DNS.

NetBird is an open-source overlay network that gives you a Tailscale-like experience you can host yourself. It builds a WireGuard mesh between your devices, punches through NAT automatically, and adds the things raw WireGuard makes you build by hand: a control plane, single sign-on, device groups, and access control policies.

In this guide I self-host the whole NetBird control plane on a single AlmaLinux 10 box, running it behind a Caddy reverse proxy that already exists on the server and already owns ports 80 and 443. By the end you will have a private mesh network where a laptop, a server, and an Android phone can reach each other by IP no matter which network they are on — and you will be able to expose a local service, such as an API on port 8010, to every device you own without opening a single port to the internet.

This is the setup I run in production, including the three mistakes that cost me the most time.

Why self-host NetBird instead of plain WireGuard

If you have used WireGuard on Ubuntu, you know the drill: generate keypairs, hand-edit wg0.conf on every peer, map AllowedIPs by hand, and hope none of your devices ever change network. It works, but it does not scale past a handful of machines, and it has no concept of identity.

NetBird keeps WireGuard as the data plane — same speed, same crypto — and adds a control plane on top:

ConcernPlain WireGuardNetBird
Key distributionManual, per peerAutomatic, via the management server
NAT traversalManual port-forwarding or a public relayAutomatic (STUN/ICE, with a relay fallback)
Adding a deviceEdit config on every existing peernetbird up on the new device
Who may talk to whomAllowedIPs by handGroup-based access policies
IdentityNone (keys only)SSO / IdP-backed users

And unlike the SaaS offering, self-hosting means the coordination server, the user database and the relay all live on hardware you control.

What we are building

Modern NetBird ships a combined server: management, signal, relay and STUN in a single binary and a single container, plus an embedded Dex identity provider for local user management. That is a huge simplification — older guides had you wire up five containers plus a separate Zitadel or Keycloak instance. You no longer need any of that.

The deployment looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
                 DNS (A record, no CDN proxy)
                 netbird.example.com  ->  203.0.113.10
                              |
        Internet -- 443/tcp --+            3478/udp (STUN, direct)
                              v                    |
   +--------------------------------------------- | ---------------+
   | AlmaLinux 10 box (Docker)                     |               |
   |                                               |               |
   |   caddy  (TLS edge, already running)          |               |
   |        |                                      |               |
   |        +-- netbird.example.com --+            |               |
   |                                  v            v               |
   |   docker network: web (shared)                                |
   |        +------------------+   +--------------------------+    |
   |        | netbird-dashboard|   | netbird-server           |    |
   |        |  (:80)           |   |  Mgmt + Signal + Relay   |    |
   |        +------------------+   |  + STUN + embedded Dex   |    |
   |                               |  SQLite -> netbird_data  |    |
   |                               +--------------------------+    |
   +---------------------------------------------------------------+

Two containers. All the gRPC, WebSocket and relay traffic is multiplexed over 443 through Caddy. Only STUN (3478/udp) is published directly, because NAT traversal needs a raw UDP path that cannot be proxied.

The key wrinkle this guide solves: the box already runs Caddy for other sites, so NetBird cannot bring its own reverse proxy or bind 80/443. NetBird’s installer supports exactly this case, but the option is easy to miss and one of its generated values is wrong out of the box.

Prerequisites

  • A Linux VM with a public IP. Minimum 1 CPU / 2 GB RAM — mine is AlmaLinux 10.2.
  • Docker with the Compose v2 plugin, plus curl and jq.
  • A public domain you can point at the box. I use netbird.example.com; substitute your own everywhere below.
  • An existing Caddy container on a known Docker network. If you have no reverse proxy yet, pick option 0 in the installer instead and let NetBird run its own Traefik — the rest of this guide still applies.

Confirm the base system:

1
2
3
cat /etc/almalinux-release
docker --version
docker compose version
1
2
3
AlmaLinux release 10.2 (Lavender Lion)
Docker version 29.6.1, build 8900f1d
Docker Compose version v5.3.1

New to Compose? Start with Getting Started With Docker Compose.

Ports you must open

PortProtocolPurposeWhere to open it
80tcpACME HTTP-01 challenge (Let’s Encrypt)Host firewall + cloud firewall
443tcpDashboard, API, signal, relay (all multiplexed)Host firewall + cloud firewall
3478udpSTUN — NAT traversalHost firewall + cloud firewall

3478/udp is the one everybody forgets. If it is closed, NetBird still works — peers connect, handshakes succeed, traffic flows — but every connection is forced through the relay instead of going peer-to-peer. You get a working VPN that is mysteriously slow, with no error message anywhere. We will verify this explicitly later.

Step 1: Prepare the box (and a firewalld trap)

Open the STUN port and create the install directory:

1
2
3
4
5
6
sudo firewall-cmd --permanent --add-port=3478/udp
sudo firewall-cmd --reload
sudo systemctl restart docker

sudo mkdir -p /opt/netbird
sudo chown "$USER":"$USER" /opt/netbird

That systemctl restart docker is not optional, and it is the first trap.

On firewalld-based distros, firewall-cmd --reload flushes Docker’s iptables FORWARD and isolation rules. Docker installs those rules when the daemon starts and never re-adds them, so a reload silently severs all container-to-container traffic on the box. The symptom is baffling: containers are healthy, docker compose ps is green, but everything talks to nothing — ping between containers is 100% loss and your reverse proxy starts returning “host is unreachable” for sites that have nothing to do with NetBird.

Restarting the Docker daemon re-installs the rules. Containers with restart: unless-stopped come back on their own in about fifteen seconds.

Confirm the port took:

1
sudo firewall-cmd --list-ports
1
6443/tcp 3478/udp

Step 2: Point DNS at the box (do not proxy it)

Create an A record for your NetBird host:

TypeNameContentProxy
Anetbird203.0.113.10DNS only

If you use Cloudflare, the proxy status must be grey-cloud (DNS only). This is the second trap. An orange-cloud proxy will break you in two ways at once: Cloudflare does not proxy the raw UDP that STUN needs, and it terminates TLS itself, which interferes with NetBird’s gRPC streams and with the Let’s Encrypt HTTP-01 challenge your Caddy is trying to complete.

Verify it resolves to the origin, not to a CDN edge:

1
dig +short netbird.example.com
1
203.0.113.10

If you plan to use NetBird’s optional built-in proxy for exposing internal web apps, also add a wildcard CNAME *.netbird -> netbird.example.com. It is not needed for the setup in this guide.

Step 3: Open the cloud firewall

The host firewall is only half of it. On AWS, edit the instance’s security group and allow inbound 3478/udp from 0.0.0.0/0. On other clouds, do the equivalent in the network firewall. 80/tcp and 443/tcp are presumably already open if Caddy is serving other sites.

Step 4: Run the installer, choosing External Caddy

NetBird ships a getting-started.sh script that renders the Compose file and config for you:

1
2
3
4
5
6
7
cd /opt/netbird
curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh \
  -o getting-started.sh

NETBIRD_DOMAIN=netbird.example.com \
NETBIRD_LETSENCRYPT_EMAIL=[email protected] \
  bash getting-started.sh

The script is interactive. Answer like this:

PromptAnswer
Which reverse proxy will you use?4 (External Caddy)
Bind container ports to localhost only?Y
Docker network your Caddy is one.g. web
Identity providerEnter — embedded Dex (default)
Press Enter when your reverse proxy is readyEnter

The full menu is 0 Traefik (bundled), 1 existing Traefik, 2 Nginx, 3 Nginx Proxy Manager, 4 External Caddy, 5 other/manual. Option 4 is the one that generates a Caddyfile snippet instead of trying to bind 80/443 itself.

Answering Y to localhost-only binding matters: it publishes the containers on 127.0.0.1 only, so nothing is exposed to the internet directly. Caddy reaches them over the shared Docker network by container name.

The script writes four files and starts the stack:

1
2
3
4
5
/opt/netbird/
├── docker-compose.yml       # the two services
├── config.yaml              # server config — CONTAINS SECRETS
├── dashboard.env            # dashboard OIDC settings
└── caddyfile-netbird.txt    # snippet to paste into your Caddyfile

The generated Compose file is small:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
services:
  dashboard:
    image: netbirdio/dashboard:latest
    container_name: netbird-dashboard
    restart: unless-stopped
    networks: [netbird, web]
    ports:
      - '127.0.0.1:8080:80'      # localhost only; Caddy reaches it over the docker network
    env_file:
      - ./dashboard.env

  netbird-server:
    image: netbirdio/netbird-server:latest
    container_name: netbird-server
    restart: unless-stopped
    networks: [netbird, web]
    ports:
      - '127.0.0.1:8081:80'      # localhost only
      - '3478:3478/udp'          # STUN — published directly, must be open in the cloud firewall
    volumes:
      - netbird_data:/var/lib/netbird
      - ./config.yaml:/etc/netbird/config.yaml
    command: ["--config", "/etc/netbird/config.yaml"]

volumes:
  netbird_data:

networks:
  netbird:
  web:
    external: true

Note web is declared external: true — it is your existing Caddy’s network, not one NetBird creates.

Step 5: Fix trustedHTTPProxies (the installer gets this wrong)

Here is the third trap, and the one that will waste your afternoon.

The installer hardcodes Traefik’s IP address into config.yaml regardless of which reverse proxy you picked. If you chose External Caddy, the value is simply wrong, and the server will not trust the X-Forwarded-* headers your Caddy sends. The visible result is a dashboard that loads perfectly but whose login silently fails, because the server thinks the request arrived over plain HTTP and builds its OAuth2 redirect URIs with the wrong scheme.

Open /opt/netbird/config.yaml and find:

1
2
3
  reverseProxy:
    trustedHTTPProxies:
      - "172.30.0.10/32"      # <-- Traefik's IP. Wrong for us.

Replace it with the subnet of the Docker network your Caddy is on:

1
docker network inspect web --format '{{ (index .IPAM.Config 0).Subnet }}'
1
172.18.0.0/16
1
2
sed -i 's#- "172.30.0.10/32"#- "172.18.0.0/16"#' /opt/netbird/config.yaml
docker compose restart netbird-server

While you are in that file, note the two generated secrets — server.authSecret and server.store.encryptionKey. The encryption key decrypts sensitive columns in the database. Back it up together with the data volume, or a restore will produce a database you cannot read. The rest of the file should look like this (secrets redacted):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server:
  listenAddress: ":80"
  exposedAddress: "https://netbird.example.com:443"
  stunPorts:
    - 3478
  logLevel: "info"

  authSecret: "REDACTED"           # openssl rand -base64 32
  dataDir: "/var/lib/netbird"

  auth:
    issuer: "https://netbird.example.com/oauth2"
    signKeyRefreshEnabled: true
    dashboardRedirectURIs:
      - "https://netbird.example.com/nb-auth"
      - "https://netbird.example.com/nb-silent-auth"
    cliRedirectURIs:
      - "http://localhost:53000/"

  reverseProxy:
    trustedHTTPProxies:
      - "172.18.0.0/16"

  store:
    engine: "sqlite"
    encryptionKey: "REDACTED"      # openssl rand -base64 32

Step 6: Wire it into your existing Caddy

Append the generated snippet to your Caddyfile. It looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
netbird.example.com {
	# Native gRPC needs HTTP/2 cleartext to the backend
	@grpc header Content-Type application/grpc*
	reverse_proxy @grpc h2c://netbird-server:80

	# Combined server paths: relay, signal, management API, OAuth2 / embedded IdP
	@backend path /relay* /ws-proxy/* /api/* /oauth2/*
	reverse_proxy @backend netbird-server:80

	# Everything else is the dashboard SPA
	reverse_proxy /* netbird-dashboard:80
}

The h2c:// on the gRPC matcher is doing real work. NetBird’s management and signal services speak native gRPC, which requires HTTP/2. TLS is terminated at Caddy, so the hop to the container is cleartext — h2c is HTTP/2 without TLS. Proxy gRPC over plain HTTP/1.1 and peers will never finish registering.

Validate before you reload, so a typo cannot take down your other sites:

1
2
docker exec caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker exec caddy caddy reload   --config /etc/caddy/Caddyfile --adapter caddyfile

Caddy now requests a Let’s Encrypt certificate for the new hostname. It usually lands in under a minute. Watch it:

1
docker logs caddy 2>&1 | grep -i netbird

Step 7: Create the first admin

On a fresh install, with zero users in the database, the dashboard redirects to an unauthenticated setup wizard. Open https://netbird.example.com and you will land on /setup. Enter an email, a name and a password (minimum 8 characters) to create the account owner.

The page disables itself the moment the first user exists, so there is no window where a stranger can claim your instance — but do not dawdle between DNS going live and creating the admin.

You can check the state at any time from the box:

1
docker exec caddy wget -qO- http://netbird-server:80/api/instance
1
{"setup_required":false}

"setup_required":true means the wizard is still open. false means your admin exists and login is now the normal flow, backed by the embedded Dex IdP.

To add teammates later, go to Team → Users → Add User. You can either send a time-limited invite link (three days by default, recipient sets their own password) or create the user directly with a generated password shown exactly once.

Step 8: Connect your peers

Install the client and point it at your own management server. On Linux and macOS:

1
2
curl -fsSL https://pkgs.netbird.io/install.sh | sh
netbird up --management-url https://netbird.example.com

That opens a browser for SSO against your embedded IdP. For headless machines — servers, CI runners, anything without a browser — create a setup key in the dashboard (Setup Keys → Add Key) and use it non-interactively:

1
netbird up --management-url https://netbird.example.com --setup-key <SETUP_KEY>

On Android and iOS, install the NetBird app, then use its “Advanced” or “Change server” screen to set the management URL to https://netbird.example.com before logging in. This is the only step that trips people up on mobile — the app defaults to NetBird’s cloud, and if you log in there first it will look like your account does not exist.

Each peer gets a stable IP from the 100.64.0.0/10 CGNAT range and an FQDN like workwiz.netbird.selfhosted.

Step 9: Verify the mesh (and check you are not stuck on the relay)

1
netbird status
1
2
3
4
5
6
7
Management: Connected
Signal: Connected
Relays: 2/2 Available
FQDN: workwiz.netbird.selfhosted
NetBird IP: 100.69.27.83/16
Interface type: Userspace
Peers count: 3/5 Connected

Now the important one — -d for detail:

1
netbird status -d
1
2
3
4
5
6
7
8
9
 nr-macmini1.netbird.selfhosted:
  NetBird IP: 100.69.103.95
  Status: Connected
  -- detail --
  Connection type: Relayed
  ICE candidate (Local/Remote): -/-
  Relay server address: rels://netbird.example.com:443
  Last WireGuard handshake: 21 seconds ago
  Latency: 0s

Connection type: Relayed on every peer means your STUN port is not reachable. The VPN works, but all traffic is being bounced through your server instead of going directly between devices — you are paying for bandwidth and latency you do not need to pay for.

Go back and check 3478/udp in both firewalls:

1
2
sudo firewall-cmd --list-ports | grep 3478
nc -zvu netbird.example.com 3478

When NAT traversal is working, that line reads Connection type: P2P and you will see real ICE candidates. A handful of hostile double-NAT or CGNAT networks will still fall back to the relay, and that is expected — the relay exists precisely for those. What is not expected is every peer being relayed.

Step 10: Expose a local service to your devices

This is the payoff. Say you are running an API on your laptop, bound to port 8010, and you want to hit it from your phone.

There is no port to forward and no firewall rule to write. The peer already has a NetBird IP that every other peer can route to. You only need two things.

First, bind the service to all interfaces, not to loopback. A service on 127.0.0.1 is invisible to the tunnel — the packets arrive on the WireGuard interface and there is nothing listening there.

1
2
3
4
5
# Wrong — only reachable from the machine itself
uvicorn app.main:app --host 127.0.0.1 --port 8010

# Right — reachable over the NetBird interface too
uvicorn app.main:app --host 0.0.0.0 --port 8010

Confirm what it actually bound to:

1
lsof -nP -iTCP:8010 -sTCP:LISTEN
1
2
COMMAND   PID     USER   FD   TYPE  NODE NAME
Python  98881 eutychus    3u  IPv4   TCP *:8010 (LISTEN)

*:8010 is what you want. 127.0.0.1:8010 is not.

Second, make sure an access policy allows it. On a fresh install NetBird creates a policy literally named Default that allows all traffic between all peers, using the built-in All group as both source and destination. So on day one this already works. (We tighten that in the next section.)

That is it. From the phone, browse to http://100.69.27.83:8010. From another peer:

1
curl -sS -o /dev/null -w "%{http_code}\n" http://100.69.27.83:8010/docs
1
200

The gotcha: you cannot reach your own NetBird IP from the same machine

Here is the test that will convince you the whole thing is broken when it is working perfectly. On the machine hosting the service:

1
2
3
nc -zv 127.0.0.1 8010          # succeeds
nc -zv 100.69.27.83 8010       # hangs forever
curl http://100.69.27.83:8010 --max-time 5
1
curl: (28) Connection timed out after 5005 milliseconds

The service is up, it is bound to 0.0.0.0, the peer is connected — and connecting to your own NetBird IP times out. Nothing is wrong. Look at the interface:

1
ifconfig utun100        # macOS; on Linux: ip addr show wt0
1
2
utun100: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1280
	inet 100.69.27.83 --> 100.69.27.83

It is a point-to-point interface, and the route for your own NetBird IP points into the tunnel rather than back at loopback:

1
netstat -rn | grep 100.69.27.83
1
100.69.27.83       100.69.27.83       UH     utun100

So the packet is handed to the userspace WireGuard implementation, which looks for a peer that owns 100.69.27.83, finds none — it is you, and you are not your own peer — and drops it. The connection hangs. This is most visible on macOS, where NetBird runs in userspace mode (Interface type: Userspace in netbird status).

Never test reachability from the host itself. The only valid tests are loopback (127.0.0.1) and a probe from another peer:

1
ssh other-peer 'nc -zvw5 100.69.27.83 8010 && ping -c2 100.69.27.83'
1
2
Connection to 100.69.27.83 port 8010 [tcp/*] succeeded!
2 packets transmitted, 2 packets received, 0.0% packet loss

I lost an hour to this, chasing the macOS application firewall and NetBird ACLs, when the service had been reachable from my phone the entire time.

Exposing a whole subnet

If you want peers to reach resources that cannot run the NetBird client at all — a printer, a managed database, an appliance on your office LAN — use Networks (routing peers) instead. You nominate one peer on that LAN as the router and advertise the subnet through it. Same mesh, no client on the target.

Step 11: Lock down access control

The permissive Default policy is fine for a personal network, but it means any peer can reach any port on any other peer. Tightening it is a two-minute job and it is the whole reason to run a zero-trust overlay rather than a flat VPN.

  1. Create groups that reflect reality — say laptops, servers, phones.
  2. Delete or disable the Default policy.
  3. Add narrow policies in its place. Under Access Control → Policies → Add policy, set a source group and a destination group, choose protocol TCP, and list the destination ports. Port ranges use start-end, e.g. 8000-9000.

For the API above, a policy of source phones → destination laptops, TCP, port 8010 replaces “everything can reach everything” with exactly the access you intended. The All group cannot be deleted or renamed, so it remains available if you ever need a break-glass rule.

If you want to go further, posture checks let you require a minimum client version, a specific OS, or a known geolocation before a peer is allowed to connect at all.

Operating the thing

Status and logs

1
2
3
cd /opt/netbird
docker compose ps
docker logs -f netbird-server
1
2
3
NAME                IMAGE                             STATUS      PORTS
netbird-dashboard   netbirdio/dashboard:latest        Up 9 days   127.0.0.1:8080->80/tcp
netbird-server      netbirdio/netbird-server:latest   Up 9 days   0.0.0.0:3478->3478/udp, 127.0.0.1:8081->80/tcp

Upgrade. Read the release notes first, back up, then:

1
2
3
cd /opt/netbird
docker compose pull netbird-server dashboard
docker compose up -d --force-recreate netbird-server dashboard

Clients nag you about new versions in netbird status -d under Events, so you will know when you are behind.

Back up. The entire state is the SQLite database in the named volume, plus config.yaml:

1
2
3
4
5
6
docker run --rm \
  -v netbird_netbird_data:/data \
  -v /tmp:/out \
  alpine tar czf /out/netbird_$(date -u +%Y%m%dT%H%M%SZ).tar.gz -C /data .

cp /opt/netbird/config.yaml /secure/backup/location/

Back up both, together. config.yaml holds the store.encryptionKey that decrypts sensitive columns in that database; the archive without the key is not a backup, it is a paperweight.

Restore:

1
2
3
4
5
cd /opt/netbird
docker compose down
docker run --rm -v netbird_netbird_data:/data -v /tmp:/out alpine \
  sh -c "rm -rf /data/* && tar xzf /out/<backup>.tar.gz -C /data"
docker compose up -d

Troubleshooting

Every container on the box lost network after I touched the firewall. You ran firewall-cmd --reload, which flushed Docker’s iptables rules. Run sudo systemctl restart docker. See Step 1 — this affects every container on the host, not just NetBird.

The certificate never gets issued. Check dig +short netbird.example.com returns your origin IP and that the record is not behind a CDN proxy. Confirm 80/tcp is reachable from the internet for the ACME HTTP-01 challenge. Then read the Caddy logs.

The dashboard loads but login fails silently. trustedHTTPProxies in config.yaml still has the installer’s hardcoded Traefik IP. Point it at your Caddy’s Docker subnet and restart the server. See Step 5.

Peers connect but everything is Relayed. 3478/udp is blocked. Check the host firewall and the cloud security group.

A peer cannot reach a service on its own NetBird IP. Expected. See Step 10 — test from another peer, not from the host.

Peer FQDNs do not resolve. If netbird status shows Nameservers: 0/0 Available, name resolution for *.netbird.selfhosted may not be configured. Use the peer’s IP, or set up a nameserver group in the dashboard.

Conclusion

You now have a self-hosted NetBird control plane on AlmaLinux 10: two containers, sharing an existing Caddy, with an embedded identity provider, issuing WireGuard mesh connectivity to every device you own. Services bound to 0.0.0.0 on any peer are reachable from every other peer without a single port exposed to the internet, and access control policies decide who may reach what.

The three things that will cost you time, in order: firewall-cmd --reload silently breaking Docker networking, the installer hardcoding Traefik’s IP into trustedHTTPProxies no matter which proxy you chose, and 3478/udp being closed in the cloud firewall so every connection quietly falls back to the relay. Get those right and the rest is fifteen minutes of work.

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy