Static WireGuard, Headscale, Netbird: three different approaches
Static WireGuard (see our WireGuard guide) relies on a fixed configuration: each peer must know the public key and reachable address of its counterparts, and every new peer means editing /etc/wireguard/wg0.conf on the others. Effective for two or three fixed servers. Unmanageable once an agency fleet mixes VPSs, roaming laptops and offices behind CGNAT: adding a machine means touching all the others; removing a technician means revoking their key everywhere.
Headscale reimplements the Tailscale coordination server: you keep the official Tailscale clients and get a flat network with no user cap. Isolation between clients goes through a single ACL policy file for the whole network. It is the right choice for a single team that wants to stay in the Tailscale ecosystem.
Netbird takes the opposite route: it brings its own client, its own control plane and a notion of groups linked by granular access rules, managed from the web interface included out of the box. A single instance can host several fully partitioned fleets, which makes it the natural tool for an agency managing separate clients.
Netbird architecture: one combined server
The current installation script no longer deploys four separate containers. It generates a Docker Compose stack of three services:
netbird-server — the combined server. A single container brings together Management (WireGuard key distribution, access rule enforcement, REST API), Signal (exchange of ICE connection descriptors between peers), the Relay and an embedded STUN server. It is configured by a single config.yaml, which replaces the former management.json and relay.env. SQLite storage by default, PostgreSQL as an option.
netbird-dashboard — the web interface that consumes the Management API: groups, peers, access rules, enrollment keys, no command line needed.
netbird-traefik — the reverse proxy that terminates TLS (automatic Let's Encrypt certificate) and routes gRPC, WebSocket and API traffic to the server.
Two changes matter compared with older tutorials. The relay is no longer COTURN: since v0.29, Netbird uses its own implementation, which carries relayed connections over port 443 (WebSocket over TCP, or QUIC over UDP) instead of allocating a range of UDP ports. And authentication is built in: since v0.62, an embedded identity provider (based on Dex) manages local users, with no Zitadel or Keycloak to deploy. A fifth service, the Netbird proxy, is added if you enable it to publish services on the internet (see below).
All traffic between clients travels over end-to-end encrypted WireGuard: the server only sees enrollment metadata, never application traffic. On licensing, the client is under BSD-3-Clause; the management/, signal/, relay/ and combined/ directories of the repository are under AGPLv3.
Prerequisites
A VPS dedicated to the control plane. Plan a VPS separate from your client nodes. The Netbird documentation asks for at least 1 vCPU and 2 GB of RAM; a VPS Solo is fine to start with.
Ports to open on the control VPS:
80 (TCP) — Let's Encrypt validation and redirect to HTTPS.443 (TCP) — Dashboard, Management API and gRPC, Signal, Relay over WebSocket.3478 (UDP) — STUN embedded in the server.
The UDP range 49152-65535 is no longer needed: it only served the COTURN relay of versions before v0.29, and stays open only if old clients still depend on it. Client nodes have no inbound port to open: the Netbird client makes outbound connections to the control plane.
Software required on the control VPS: Docker with the Compose plugin, jq and curl, and a domain name whose A record points to the VPS IP (netbird.yourdomain.com). If you enable the Netbird proxy, add a *.netbird CNAME to that same name. Behind Cloudflare, the Netbird documentation sets both records to DNS only.
On each client node: the netbird client (Debian/RPM package or static binary), root or sudo access.
Deploy the control plane and connect three nodes
Run the installation script
On the control VPS, download the official script and run it:
curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bashThe script asks for your domain (
netbird.yourdomain.com), then for the reverse proxy to use: keep option[0](built-in Traefik, automatic TLS). It then offers to enable the Netbird proxy, which you can also add later. It generatesdocker-compose.yml,config.yamlanddashboard.env(plusproxy.envif the proxy is enabled), then starts the stack. For an unattended install (cloud-init, CI), exportNETBIRD_DOMAIN,NETBIRD_LETSENCRYPT_EMAILandNETBIRD_NON_INTERACTIVE=truebeforehand.Check that the services respond
From the installation directory, confirm the containers are up:
docker compose psYou should see
netbird-traefik,netbird-dashboardandnetbird-serverin therunningstate (plus the proxy if enabled). When in doubt, most configuration errors show up in the server log:docker compose logs -f netbird-serverCreate the admin account and a Setup Key
Open
https://netbird.yourdomain.comin a browser. The script deploys Netbird without any user: you are redirected to the/setuppage, where you create the administrator account (email, name, password). This page is only available while no user exists.In the Setup Keys menu, click Create Setup Key. Give it a name (
vps-client-a), choose theReusabletype (to enroll several machines with the same key) and an expiry period. Copy the key: you will need it on each node.Enroll node 1
On the first client VPS, install the Netbird client:
curl -fsSL https://pkgs.netbird.io/install.sh | shThen connect it to the control plane by pointing at your instance (without
--management-url, the client targets Netbird's cloud service):netbird up \ --management-url https://netbird.yourdomain.com \ --setup-key <YOUR_SETUP_KEY>Confirm the connection:
netbird statusYou should read
Management: ConnectedandSignal: Connected, along with a mesh IP in the100.64.x.xrange assigned by your server.Enroll nodes 2 and 3
Repeat exactly the same procedure on the other two client VPSs. Same
curlcommand to install the client, samenetbird upcommand with the same--management-urland the same--setup-key(if it is of theReusabletype).Once all three nodes are enrolled, check from node 1 that the peers are visible:
netbird status --detailThe output lists each peer with its mesh IP, state and latency. Since v0.82,
netbird status --tableshows the same information as a table.Test mesh connectivity with no public port open
Before testing, check the firewall state on node 1 — no inbound port should be open to the other nodes:
sudo ufw status numberedOnly the SSH port (22) should appear. Now ping node 2 through its mesh IP (shown in
netbird status --detail, for example100.64.0.2):ping -c 3 100.64.0.2The ping goes through the WireGuard tunnel established between the peers. If a direct connection is impossible, the relay built into the server takes over on port 443: the ping works either way, with no extra UFW rule.
Tell direct and relayed connections apart
To find out whether a peer connects directly or through the relay:
netbird status --detailThe
Connection typefield showsP2Pfor a direct connection orRelayedwhen traffic goes through the relay. To list only relayed peers:netbird status -d --filter-by-connection-type RelayedP2Pis the expected state between two VPSs with a public IPv4.Relayedremains end-to-end encrypted but adds latency: the Troubleshooting section says what to check.
Multi-client isolation: groups and access rules
Netbird's strength over Headscale is partitioning by group, driven from the interface. One thing to know first: when the account is created, Netbird sets a Default policy that lets every peer in the special All group talk to every other. As long as it exists, no isolation holds. To separate client A's servers from client B's:
1. Create one group per client in the web interface (client-a, client-b) and an agency-ops group for the team's workstations.
2. Assign each peer to its group. In the peer's page, in the groups section, add the matching group. A Setup Key can also assign a group automatically to every machine enrolled with it: plan one key per client.
3. Define the access rules (Access Control → Policies). A client-a-internal policy allows traffic between peers of the client-a group. An ops-ssh policy lets only the agency-ops group reach port 22 of the client groups: a compromised machine in the mesh then cannot serve as a pivot to every VPS. No rule is created between client-a and client-b.
4. Delete the Default policy once the replacement policies are in place. In the reverse order, all communication between peers stops.
Result: an administrator's workstation for client A reaches VPS A through its mesh IP but cannot see VPS B, and the agency-ops group remains the only cross-cutting member. You can also define Network Routes: a peer acts as a router for a private subnet (e.g. 192.168.10.0/24) and exposes it to the other peers of the group, with no Netbird client on each machine of that subnet.
Close public SSH: listen on the mesh interface only
Once the nodes are enrolled, administration no longer needs an SSH port open to the internet: ssh [email protected] travels entirely inside the encrypted tunnel. The Netbird client creates the WireGuard interface wt0 (default name); allow SSH on that interface, then deny it elsewhere:
sudo ufw allow in on wt0 to any port 22 proto tcp
sudo ufw deny 22/tcp
sudo ufw status numberedUFW applies the first matching rule: the wt0 rule must appear before the deny. Keep your current SSH session open and test a new connection through the mesh IP before closing it: if the tunnel does not come up, you still have a way out. The ops-ssh policy seen above then limits who, inside the mesh, can reach that port.
`netbird expose`: publish a local service without opening a port
The mesh links your machines together. To make a service reachable from the internet without touching the VPS firewall, Netbird has offered the netbird expose command since v0.66 (February 2026). It relies on the Netbird proxy: on a self-hosted instance, the proxy must be enabled (at install time or later), with a wildcard DNS record pointing to the server.
An administrator must first allow the feature: Settings → Clients → Peer Expose, restricting the allowed peer groups if needed. Then, on the VPS where, for example, Grafana runs on 127.0.0.1:3000:
netbird expose 3000 --with-name-prefix grafana --with-user-groups supervision-adminsThe command prints a public URL on the proxy domain and keeps the session open; Ctrl+C removes it. The service is ephemeral: the client renews it every 30 seconds, and the server deletes it roughly 90 to 120 seconds after the last renewal if the client disappears. Port 3000 stays closed on the VPS firewall.
Without an authentication option, the URL is public to anyone who knows it. Three protections exist for HTTP: --with-user-groups (SSO login, limited to the listed groups), --with-password <password> (a password you choose) and --with-pin <6 digits>. --protocol tcp publishes a non-HTTP service, with no authentication possible. Each peer can hold 10 simultaneous sessions, and the command fails if Block Inbound Connections is enabled on the peer. For a permanent service, create it in the Dashboard instead.
Netbird, Headscale and manual WireGuard at a glance
Scroll the table
| Criterion | Self-hosted Netbird | Headscale | Manual WireGuard |
|---|---|---|---|
| Peer management | Automatic, control plane and web interface | Automatic, official Tailscale clients | Manual, a `wg0.conf` file on each node |
| NAT and CGNAT | Embedded STUN, relay on port 443 | DERP servers (optional embedded DERP server) | Requires a public IP or port forwarding |
| Partitioning | Groups and policies in the interface | ACL policy file (HuJSON) | None, apart from each peer's `AllowedIPs` |
| Publishing a service | `netbird expose` or the Netbird proxy | Not built in | SSH tunnel or a reverse proxy you set up yourself |
| License | BSD-3 (client), AGPLv3 (server) | BSD-3-Clause | Free software (Linux kernel and tools) |
Day-to-day operations
Renew or revoke a Setup Key. In the web interface, Setup Keys → your key → Revoke. Peers already enrolled keep their connection; new enrollment attempts with that key are refused. Create a new key for future enrollments.
Revoke a peer. Peers → select the peer → Delete. The node is removed from the mesh; on the client side, the connection to Management drops and the WireGuard tunnels to that peer are torn down.
API access for automation. Netbird exposes a documented REST API. Create a service user (Team → Service Users) and its access token, then drive everything from your Ansible scripts or CI pipelines:
curl -s https://netbird.yourdomain.com/api/peers \
-H 'Authorization: Token <PAT>'Monitoring. The combined server exposes its Prometheus metrics on /metrics, port 9090 by default (the server.metricsPort key in config.yaml): a single scrape target covers Management, Signal and Relay. Connect Grafana to it to follow connected peers and relay activity.
Upgrading. From the installation directory, pull the new images, then recreate the containers:
docker compose pull netbird-server dashboard
docker compose up -d --force-recreate netbird-server dashboardAdd proxy to both commands if it is enabled. If docker-compose.yml pins versions, update the image: tags first: docker compose pull does not change them. Peers lose the control plane for a few seconds during the restart; schedule the operation outside busy hours. Traefik is upgraded separately from Netbird.
Hardening: MFA on the Dashboard and data backups
The embedded identity provider handles two-factor authentication for local accounts: Settings → Authentication → Local MFA requires a TOTP code from every user. You can also connect an OIDC provider (Keycloak, Authentik, Microsoft Entra ID…) and apply your own MFA policy there.
The persistent state of your network (Management database, encryption keys) lives in the netbird_data Docker volume, mounted on /var/lib/netbird: losing it means re-enrolling all your peers. Stop the stack to get a consistent copy, back up the configuration files and the volume, then restart:
docker compose stop
mkdir -p backup
cp docker-compose.yml config.yaml dashboard.env backup/
docker compose cp -a netbird-server:/var/lib/netbird/ backup/
docker compose startIf the proxy is enabled, add proxy.env and traefik-dynamic.yaml. Keep at least seven days of backups, off the VPS.
Troubleshooting
Peers stuck in Relayed.
The relay works, but the direct connection fails. First check that UDP port 3478 on the control VPS is open (ufw status), then that the nodes can send outbound UDP. On a VPS with a public IP and no NAT, a host firewall that drops unsolicited inbound UDP is enough to break the direct connection; the Netbird documentation then suggests sudo ufw allow in proto udp to any port 49152:65535, at the cost of a wider opening, or accepting the relay. A Connected peer that does not answer pings often points to the same block, on the wt0 interface side.
UDP blocked by a corporate network.
Nothing to configure: since v0.36, the client tries the relay over QUIC (UDP/443) and over WebSocket (TCP/443), and keeps whichever answers first. If only outbound TCP/443 gets through, the peer stays connected, in relayed mode.
Peer stuck in Connecting.
Enrollment succeeded but the tunnel does not form. Check that netbird status does show Signal: Connected, that the server announces a correct public address (the exposedAddress field of config.yaml) and that ports 443/TCP and 3478/UDP are reachable from the node.
Reading the logs.
On the server, docker compose logs -f netbird-server. On the client, the log is written by default to /var/log/netbird/client.log. For a full diagnosis, netbird debug for 5m switches the client to detailed logging for five minutes, then produces a diagnostic archive (netbird debug bundle produces it right away).
DNS resolution between peers.
Netbird assigns a DNS name to each peer, shown in the Domain Name field of its page. If pinging by name fails while ping 100.64.0.2 works, check that the service is running on the peer (sudo netbird service status) and that the system's DNS does go through Netbird (resolvectl status).
Netbird proxy stuck on "Issuing certificate…"
When the proxy is enabled, one symptom keeps coming back: the service you created stays on "Issuing certificate…" (status certificate_pending, or certificate_failed) and is never reachable. Netbird issue #5517, opened on 5 March 2026, is still open as of 7 November 2026 with 36 comments and the triage-needed label. The most frequent message in the proxy log, no viable challenge type found, only says that the certificate authority got no valid answer to its challenges: there is no single cause. Start by reading the log (docker compose logs proxy | grep -i acme) and note the exact error, then rule out the causes one by one.
Diagnostic order for a certificate that does not come through
- Certificate folder permissions — a
permission deniederror on/certsis the only case settled by a repository collaborator: no update will fix it, the file system permissions are at fault. Check which user the container runs as before changing permissions. - DNS, CAA and ports — the name must resolve to the proxy and no CAA record may exclude Let's Encrypt. Port 443 must be reachable from the internet for the
tls-alpn-01challenge (the default), port 80 forhttp-01. Let's Encrypt validates from several regions of the world: geo-blocking makes validation fail. - CDN or upstream proxy — Cloudflare and some appliances do not pass the
acme-tls/1ALPN protocol through. In that case switch toNB_PROXY_ACME_CHALLENGE_TYPE=http-01inproxy.env. - Traefik in front — if Traefik passes TLS through to the proxy, its HTTPS entry point must carry
allowACMEByPass=true, which the installation script sets onwebsecure. On an existing Traefik, use the name of your own entry point; participants in the issue had to add it to the HTTP entry point as well, others already had it with no result. - What is not established — none of these leads is "the" cause: test them on your own installation before drawing conclusions.
Closed origin: the certificate through DNS-01 with lego
The tls-alpn-01 and http-01 challenges require the authority to reach your proxy from the internet, which conflicts with an origin closed to a CDN's ranges or with geo-blocking. The dns-01 challenge removes that constraint: it proves control of the domain with a temporary TXT record created through your DNS provider's API. The Netbird proxy does not support it natively: the request (issue #5450, opened on 25 February 2026) is still open. The Netbird documentation does, however, describe a "private proxy with lego" setup: lego obtains and renews a wildcard certificate over DNS-01, a deploy hook drops it into a shared volume, and the proxy hot-reloads it after each renewal.
Points to watch in the lego setup
- Proxy variables —
NB_PROXY_WILDCARD_CERT_DIRpoints to the wildcard certificate folder,NB_PROXY_PRIVATE=trueenables private listeners on the mesh. Pin a proxy image that knows both variables. - Private mode closes nothing on its own —
NB_PROXY_PRIVATEdoes not disable the proxy's ordinary listener. Remove the published ports and the ingress routes (Traefik labels, Caddy, nginx) leading to the container yourself. - Wildcard scope —
*.proxy.yourdomain.comcovers only one level of subdomain. A name outside that coverage falls back to the built-in ACME, which cannot succeed if the proxy is fully private. - Certificate exposure — the wildcard and the base domain appear in Certificate Transparency logs, not the names of the services it covers; a compromised wildcard key, on the other hand, affects all of those names.
- Operations — use Let's Encrypt's production ACME server (its test certificates are not trusted), back up the lego and certificate volumes, and pin tested versions of lego and the proxy.
What the mesh changes for fleet management
A self-hosted Netbird mesh replaces three layers you maintain by hand: WireGuard key distribution, UFW rules between servers and the documentation of cross access. Each new client VPS enrolls with one command; each revocation is immediate and centralized. Public SSH can be closed, and netbird expose publishes a service on demand without opening a port.
Group isolation lets you grow without risk of collision: the servers of two different clients cannot see each other, even when they run on the same infrastructure. And the control plane stays under your control: no dependency on a third-party SaaS, no seat limit, no per-node subscription.
To go further, describe node provisioning in Ansible (see our Ansible guide): installing the client, the netbird up command and the UFW rules on wt0 become idempotent tasks in a reusable role.