Tutorial

Deployment

Netbird: Self-Hosted WireGuard Mesh VPN on a VPS

You manage servers spread across several client accounts and end up opening ports, maintaining separate UFW rules or distributing static WireGuard keys by hand. Headscale addresses the same need for a flat network — we covered it in <a href="/en/blog/self-host-headscale-on-a-vps-your-own-tailscale-control-server">a dedicated guide</a>. Netbird is another answer, designed for managing several isolated networks: peer groups, per-network access rules and a built-in web interface. This guide deploys the Netbird control plane (more than 30,000 GitHub stars, AGPLv3 on the server side) on a dedicated VPS, connects three nodes in a mesh without opening a single public port, then covers per-client isolation, publishing a service with `netbird expose`, and troubleshooting.

18 min read7 steps
Contents· Static WireGuard, Headscale, Netbird: three different approaches1/16
  1. 01Static WireGuard, Headscale, Netbird: three different approaches
  2. 02Netbird architecture: one combined server
  3. 03Prerequisites
  4. 04Deploy the control plane and connect three nodes
  5. 05Multi-client isolation: groups and access rules
  6. 06Close public SSH: listen on the mesh interface only
  7. 07`netbird expose`: publish a local service without opening a port
  8. 08Netbird, Headscale and manual WireGuard at a glance
  9. 09Day-to-day operations
  10. 10Hardening: MFA on the Dashboard and data backups
  11. 11Troubleshooting
  12. 12Netbird proxy stuck on "Issuing certificate…"
  13. 13Diagnostic order for a certificate that does not come through
  14. 14Closed origin: the certificate through DNS-01 with lego
  15. 15Points to watch in the lego setup
  16. 16What the mesh changes for fleet management

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

  1. 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 | bash

    The 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 generates docker-compose.yml, config.yaml and dashboard.env (plus proxy.env if the proxy is enabled), then starts the stack. For an unattended install (cloud-init, CI), export NETBIRD_DOMAIN, NETBIRD_LETSENCRYPT_EMAIL and NETBIRD_NON_INTERACTIVE=true beforehand.

  2. Check that the services respond

    From the installation directory, confirm the containers are up:

    docker compose ps

    You should see netbird-traefik, netbird-dashboard and netbird-server in the running state (plus the proxy if enabled). When in doubt, most configuration errors show up in the server log:

    docker compose logs -f netbird-server
  3. Create the admin account and a Setup Key

    Open https://netbird.yourdomain.com in a browser. The script deploys Netbird without any user: you are redirected to the /setup page, 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 the Reusable type (to enroll several machines with the same key) and an expiry period. Copy the key: you will need it on each node.

  4. Enroll node 1

    On the first client VPS, install the Netbird client:

    curl -fsSL https://pkgs.netbird.io/install.sh | sh

    Then 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 status

    You should read Management: Connected and Signal: Connected, along with a mesh IP in the 100.64.x.x range assigned by your server.

  5. Enroll nodes 2 and 3

    Repeat exactly the same procedure on the other two client VPSs. Same curl command to install the client, same netbird up command with the same --management-url and the same --setup-key (if it is of the Reusable type).

    Once all three nodes are enrolled, check from node 1 that the peers are visible:

    netbird status --detail

    The output lists each peer with its mesh IP, state and latency. Since v0.82, netbird status --table shows the same information as a table.

  6. 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 numbered

    Only the SSH port (22) should appear. Now ping node 2 through its mesh IP (shown in netbird status --detail, for example 100.64.0.2):

    ping -c 3 100.64.0.2

    The 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.

  7. Tell direct and relayed connections apart

    To find out whether a peer connects directly or through the relay:

    netbird status --detail

    The Connection type field shows P2P for a direct connection or Relayed when traffic goes through the relay. To list only relayed peers:

    netbird status -d --filter-by-connection-type Relayed

    P2P is the expected state between two VPSs with a public IPv4. Relayed remains 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 numbered

UFW 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-admins

The 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

CriterionSelf-hosted NetbirdHeadscaleManual WireGuard
Peer managementAutomatic, control plane and web interfaceAutomatic, official Tailscale clientsManual, a `wg0.conf` file on each node
NAT and CGNATEmbedded STUN, relay on port 443DERP servers (optional embedded DERP server)Requires a public IP or port forwarding
PartitioningGroups and policies in the interfaceACL policy file (HuJSON)None, apart from each peer's `AllowedIPs`
Publishing a service`netbird expose` or the Netbird proxyNot built inSSH tunnel or a reverse proxy you set up yourself
LicenseBSD-3 (client), AGPLv3 (server)BSD-3-ClauseFree 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 dashboard

Add 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 start

If 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 denied error on /certs is 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-01 challenge (the default), port 80 for http-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/1 ALPN protocol through. In that case switch to NB_PROXY_ACME_CHALLENGE_TYPE=http-01 in proxy.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 on websecure. 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_DIR points to the wildcard certificate folder, NB_PROXY_PRIVATE=true enables private listeners on the mesh. Pin a proxy image that knows both variables.
  • Private mode closes nothing on its own — NB_PROXY_PRIVATE does 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.com covers 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.

Your VPS and your clients' accounts on one account

Your ServOrbit.com client area brings your domains, hosting and VPS together, with their due dates. The Reseller space adds your clients' cPanel accounts. Add a VPS dedicated to the Netbird control plane.