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:
| Concern | Plain WireGuard | NetBird |
|---|---|---|
| Key distribution | Manual, per peer | Automatic, via the management server |
| NAT traversal | Manual port-forwarding or a public relay | Automatic (STUN/ICE, with a relay fallback) |
| Adding a device | Edit config on every existing peer | netbird up on the new device |
| Who may talk to whom | AllowedIPs by hand | Group-based access policies |
| Identity | None (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:
| |
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
curlandjq. - 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
0in the installer instead and let NetBird run its own Traefik — the rest of this guide still applies.
Confirm the base system:
| |
| |
New to Compose? Start with Getting Started With Docker Compose.
Ports you must open
| Port | Protocol | Purpose | Where to open it |
|---|---|---|---|
| 80 | tcp | ACME HTTP-01 challenge (Let’s Encrypt) | Host firewall + cloud firewall |
| 443 | tcp | Dashboard, API, signal, relay (all multiplexed) | Host firewall + cloud firewall |
| 3478 | udp | STUN — NAT traversal | Host 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:
| |
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:
| |
| |
Step 2: Point DNS at the box (do not proxy it)
Create an A record for your NetBird host:
| Type | Name | Content | Proxy |
|---|---|---|---|
| A | netbird | 203.0.113.10 | DNS 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:
| |
| |
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:
| |
The script is interactive. Answer like this:
| Prompt | Answer |
|---|---|
| Which reverse proxy will you use? | 4 (External Caddy) |
| Bind container ports to localhost only? | Y |
| Docker network your Caddy is on | e.g. web |
| Identity provider | Enter — embedded Dex (default) |
| Press Enter when your reverse proxy is ready | Enter |
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:
| |
The generated Compose file is small:
| |
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:
| |
Replace it with the subnet of the Docker network your Caddy is on:
| |
| |
| |
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):
| |
Step 6: Wire it into your existing Caddy
Append the generated snippet to your Caddyfile. It looks like this:
| |
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:
| |
Caddy now requests a Let’s Encrypt certificate for the new hostname. It usually lands in under a minute. Watch it:
| |
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:
| |
| |
"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:
| |
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:
| |
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)
| |
| |
Now the important one — -d for detail:
| |
| |
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:
| |
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.
| |
Confirm what it actually bound to:
| |
| |
*: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:
| |
| |
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:
| |
| |
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:
| |
| |
It is a point-to-point interface, and the route for your own NetBird IP points into the tunnel rather than back at loopback:
| |
| |
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:
| |
| |
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.
- Create groups that reflect reality — say
laptops,servers,phones. - Delete or disable the
Defaultpolicy. - 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
| |
| |
Upgrade. Read the release notes first, back up, then:
| |
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:
| |
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:
| |
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.
Related content
- How to set up WireGuard on Ubuntu 22.04
- Getting Started With Docker Compose: A Guide With Examples
- How to install and configure Gatus for health check monitoring using Docker Compose
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.