Use case · Mesh VPN control plane

NetBird self hosted: the control plane, its ports, and claiming the first owner before anyone else

Self-hosting NetBird means running its control plane yourself: management, signal, relay and an embedded STUN server, packed since the combined setup into one netbird-server container behind a bundled Traefik, on a Linux host with 1 CPU and 2 GB RAM or more, public TCP 80 and 443, UDP 3478 and a DNS name that points at it. The peers keep their WireGuard tunnels between your machines; the server tells them about each other and relays their traffic when a direct path fails.

See the machines

What the self-hosted server runs

The netbirdio/netbird README lays out the parts. Every machine runs the NetBird agent, which manages WireGuard. Every agent connects to the Management Service, which holds the network state, hands out peer IPs and pushes updates. Agents find connection candidates with ICE, helped by STUN, and negotiate through the Signal Service with end-to-end encrypted messages. When NAT traversal fails, for example behind carrier-grade NAT, the connection falls back to the Relay Service and the WireGuard tunnel runs through it.

The quickstart deploys three containers: traefik for TLS and routing, dashboard for the web console, and netbird-server, which the configuration reference describes as management, signal, relay and STUN combined, configured by one config.yaml. The separate management, signal, relay and coturn containers of older guides are the legacy layout.

Licence, as we read the files (not legal advice): the root LICENSE file of netbirdio/netbird puts the repository under BSD-3-Clause except the management/, signal/, relay/ and combined/ directories, which are AGPL-3.0; the dashboard (netbirdio/dashboard) is AGPL-3.0 as well. The client is BSD-3: copies you pass on keep the copyright notice and licence text, with no duty to supply source. For the AGPL parts, distributing copies, modified or not, means passing on their source, and running a modified build that people use over a network means offering them its source.

Sizing the machine

The quickstart floor is 1 CPU and 2 GB RAM, with no per-peer figures. The store matters more than the core count as you grow: the default is SQLite in the netbird_data volume, which the configuration reference calls best for testing or small deployments because it cannot take concurrent writes or several management instances. PostgreSQL is the engine it recommends for production and for running more than one instance.

Bandwidth is the other input. A peer pair on a direct path keeps only its management and signal connections to the server, with no tunnel traffic through it, but every pair that falls back to the relay sends its tunnel traffic through it. On our dedicated server lines a port marked Unmetered has no transfer allowance and no overage fee, so relay traffic does not run up a meter, and the network is our own autonomous system, AS19624.

A Moonshot cartridge covers the quickstart minimum. Pick the Xeon E3-1585L v5 cartridge (the m710x) if you want an out-of-band console for the day a firewall rule locks out SSH; the other cartridges have power control only, apart from the m510. Every racked single-, dual- and quad-socket server has a full console (HPE iLO, Dell iDRAC or a Supermicro BMC). The X-Gene ARM line is out: it boots only Ubuntu 16.04 and 18.04. IPv6 is in no default build; add it at checkout if peers should reach the server over IPv6. Delivery is about 30 minutes for a racked machine once payment is approved, and 4 to 24 hours for a built-to-order line.

NetBird server ports

PortProtocolCarriesWhen
80TCPRedirect to HTTPS; the generated Traefik answers the Let's Encrypt TLS challenge on 443Always (the quickstart requires it open)
443TCPDashboard, management API and gRPC, signal gRPC, relay WebSocketAlways
3478UDPEmbedded STUN for NAT detection; it cannot pass through an HTTP proxyAlways
51820UDPWireGuard for peer-to-peer proxy connectionsOnly with the optional NetBird Proxy service
33073, 10000, 33080 TCP; 49152-65535 UDPMixedPre-v0.29 port layoutNot served by this install: upgrade clients older than v0.29 instead

Docker maps Traefik's 80 and 443 and the server's 3478/udp onto every host address, IPv4 0.0.0.0 as well as [::]. Published ports reached over IPv4 bypass ufw. Over IPv6 they bypass it only when Docker's own IPv6 networking is enabled; without that, the [::] listener is docker-proxy, an ordinary host process that ufw filters. Hence the three ufw rules below. The metrics port (9090) and health check (9000) from config.yaml stay inside the container: the generated compose file publishes neither.

Setting it up

This follows the quickstart on Ubuntu 24.04 with the built-in Traefik (option 0) and the optional reverse-proxy feature left off. Three changes are ours: Docker comes from the Ubuntu packages, the script is downloaded and read before it runs instead of piped into bash, and the first owner is created from the server through the setup API instead of the public /setup page. Run everything as root (sudo -i).

  1. Point an A record for your NetBird name, for example netbird.example.com, at the server. The script only warns when the name does not resolve, and certificates fail until it does.
  2. Firewall, with ufw's IPv6 handling switched on ahead of the first rule: sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw && ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp && ufw allow 3478/udp
  3. Docker with the compose plugin, plus jq and curl, which the script requires (these are the Ubuntu 24.04 package names; elsewhere use Docker's own install guide): apt-get update && apt-get install -y docker.io docker-compose-v2 jq curl openssl && docker compose version
  4. Fetch the script into a fresh directory, then read it: install -d -m 700 /opt/netbird && curl -fsSLo /opt/netbird/getting-started.sh https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh && echo ok
  5. Run the installer block. The variables answer every prompt, which the quickstart documents for unattended installs; the script waits until the server answers through Traefik before it returns.
  6. Paste the owner block at once. It waits for a trusted certificate, creates the owner with a random password, and stores the password in /root/nb-owner.pass at mode 600. If anything fails it stops NetBird, so the setup endpoint does not stay open.
# As root, from /opt/netbird. Set your own name and address first.
D=netbird.example.com; MAIL=you@example.com
cd /opt/netbird && if [ "$D" = netbird.example.com ] || [ "$MAIL" = you@example.com ] || ! getent ahostsv4 "$D" >/dev/null; then
  echo "STOP - set D and MAIL, and create the DNS A record first"
elif [ -e config.yaml ]; then
  echo "STOP - config.yaml exists: this directory already holds an install"
elif NETBIRD_DOMAIN="$D" NETBIRD_LETSENCRYPT_EMAIL="$MAIL" NETBIRD_NON_INTERACTIVE=true bash getting-started.sh; then
  echo "ok - paste the owner block now"
else
  [ -e docker-compose.yml ] && docker compose -f /opt/netbird/docker-compose.yml stop
  echo "STOP - the installer did not finish; NetBird is stopped so nobody can claim /setup"
fi
# Paste straight after the installer. Same D and MAIL as before.
D=netbird.example.com; MAIL=you@example.com; C="docker compose -f /opt/netbird/docker-compose.yml"
if [ -e /root/nb-owner.pass ]; then
  echo "STOP - an owner already exists; its password is in /root/nb-owner.pass"
elif [ "$D" = netbird.example.com ] || [ "$MAIL" = you@example.com ]; then
  echo "STOP - set D and MAIL"
else
  code=; n=0; until curl -fsS -o /dev/null --resolve "$D:443:127.0.0.1" "https://$D/oauth2/.well-known/openid-configuration" || [ $n -ge 30 ]; do n=$((n+1)); sleep 10; done
  install -m 600 /dev/null /root/nb-owner.json && install -m 600 /dev/null /root/nb-owner.new && install -m 600 /dev/null /root/nb-owner.out && pw=$(openssl rand -base64 24)
  [ -n "$pw" ] && echo "$pw" > /root/nb-owner.new \
    && PW="$pw" jq -n --arg e "$MAIL" '{email:$e, name:"Owner", password:env.PW}' > /root/nb-owner.json \
    && code=$(curl -sS -o /root/nb-owner.out -w '%{http_code}' --resolve "$D:443:127.0.0.1" -H 'Content-Type: application/json' -d @/root/nb-owner.json "https://$D/api/setup")
  if grep -q user_id /root/nb-owner.out && mv /root/nb-owner.new /root/nb-owner.pass; then
    echo "ok - owner $MAIL created; password in /root/nb-owner.pass"
  else
    $C stop; echo "STOP - no owner created (HTTP ${code:-none}); NetBird is stopped so nobody else can claim it"
  fi
  rm -f /root/nb-owner.json /root/nb-owner.new /root/nb-owner.out; unset pw
fi

Why the hurry: the install has no users, and the setup endpoint is unauthenticated until the first account exists. The two blocks keep that window short; they do not close it. Afterwards /api/setup refuses with HTTP 412 and /setup redirects to the login page, so a 412 in the STOP line means someone else finished setup first. Then wipe and redo: docker compose -f /opt/netbird/docker-compose.yml down --volumes, delete docker-compose.yml, config.yaml and dashboard.env from /opt/netbird (the files these defaults generate), and paste the installer block again; the same clean-up rescues a half-finished install. Any other code, such as no trusted certificate yet, means nobody claimed it: docker compose -f /opt/netbird/docker-compose.yml start and paste the owner block again at once. After signing in, move the password to your manager and shred -u /root/nb-owner.pass.

Users, identity providers and peers

No external identity provider is required: an embedded Dex server holds local users, created in the dashboard. For SSO, add an OIDC provider under Settings, Identity Providers (Client ID, Client Secret, Issuer URL) and register the Redirect URL it shows with your provider as a confidential client. Our authentik and Keycloak pages cover running either one.

People connect with netbird up and sign in through the browser. Servers join with a setup key from Settings, Setup Keys: one-off or reusable, with an expiry the create dialog pre-fills at 7 days. Without a key, netbird up starts that browser sign-in, so for an unattended server pass the key in a file with --setup-key-file (a flag defined in client/cmd/root.go of the client), which keeps it out of ps output and your shell history: install -m 600 /dev/null /root/nb-key && read -rsp "Setup key: " k && echo "$k" > /root/nb-key && netbird up --management-url https://netbird.example.com --setup-key-file /root/nb-key; unset k; shred -u /root/nb-key. The example in the docs points --management-url at http://localhost:33073; the https address of your domain is our adaptation for this combined install. netbird status confirms the connection.

Backups and restores

Per the backup guide, the parts you cannot regenerate are the configuration files and /var/lib/netbird in the netbird-server container: management database, encryption keys and state. Certificates are optional; Traefik reissues them. The block stops the containers for a consistent copy, writes a timestamped archive at mode 600, checks it lists the data directory, restarts NetBird even on failure, and deletes nothing old. Copy the archive off the machine: it holds every secret of the install.

# As root. Stops NetBird for the copy, starts it again even if a step fails.
( set -o pipefail; umask 077
  C="docker compose -f /opt/netbird/docker-compose.yml"; B=/root/netbird-backups
  T=$(date +%F-%H%M%S); W="$B/work-$T"
  trap '$C start >/dev/null 2>&1' EXIT
  mkdir -p "$W" && cp /opt/netbird/docker-compose.yml /opt/netbird/config.yaml /opt/netbird/dashboard.env "$W"/ \
  && $C stop && $C cp -a netbird-server:/var/lib/netbird/ "$W"/ && $C start \
  && [ -n "$(ls -A "$W/netbird" 2>/dev/null)" ] \
  && tar -czf "$B/netbird-$T.tar.gz" -C "$W" . \
  && tar -tzf "$B/netbird-$T.tar.gz" > "$W.list" && grep -c '^\./netbird/.' "$W.list" >/dev/null \
  && rm -rf "$W" "$W.list" && echo "ok - $B/netbird-$T.tar.gz; copy it off this server" \
  || echo "STOP - backup incomplete; look in $W and run: $C ps" )

A restore goes onto a fresh server reachable under the same domain; peers reconnect without re-enrolling once DNS points at it. Take the backup right before the move and make no changes on the old box after it (no new peers, keys or policies), or the restore will lack them. Leave the old server untouched until the new box is verified; it is the rollback.

  1. On the new server, do the firewall and Docker steps above, not the installer, and copy the archive to /root.
  2. Check the archive and stop unless it prints ok; tar's own exit status counts, so the listing goes to a file first: tar -tzf /root/netbird-T.tar.gz > /root/nb-restore.list && grep -q '^\./netbird/.' /root/nb-restore.list && echo ok (use your file's name).
  3. Unpack, refusing to land on an existing install: `[ ! -e /opt/netbird/config.yaml ] && install -d -m 700 /opt/netbird/restore && tar -xzf /root/netbird-T.tar.gz -C /opt/netbird/restore && cp -p /opt/netbird/restore/docker-compose.yml /opt/netbird/restore/config.yaml /opt/netbird/restore/dashboard.env /opt/netbird/ && echo ok`
  4. Create the container, fill its volume, start: docker compose -f /opt/netbird/docker-compose.yml create netbird-server && docker compose -f /opt/netbird/docker-compose.yml cp -a /opt/netbird/restore/netbird/. netbird-server:/var/lib/netbird/ && docker compose -f /opt/netbird/docker-compose.yml up -d
  5. Move the A record to the new address, then docker compose -f /opt/netbird/docker-compose.yml restart traefik so Traefik requests its certificate now that the name points here. Check peers, policies and setup keys in the dashboard, then delete /opt/netbird/restore and /root/nb-restore.list; the directory holds the secrets in clear.

Upgrading

Run the backup, read the release notes for netbirdio/netbird and netbirdio/dashboard, and record the digests running now: docker images --digests netbirdio/netbird-server && docker images --digests netbirdio/dashboard. Then docker compose -f /opt/netbird/docker-compose.yml pull netbird-server dashboard && docker compose -f /opt/netbird/docker-compose.yml up -d --force-recreate netbird-server dashboard. Both images are tagged latest, so a pull takes whatever is published under that tag.

Traefik is updated on its own schedule, and re-running getting-started.sh is not an upgrade, the upgrade guide says. Nor does an older tag undo a database migration: to go back, restore the pre-upgrade backup with each image: pinned to its recorded digest, for example image: netbirdio/netbird-server@sha256:...; latest would fetch the new release again.

When this is the wrong setup

One box with SQLite is one management instance. High availability needs PostgreSQL and the scaling guide, which splits the deployment across several nodes. To keep the Tailscale clients and run only a control server, see self-hosting Headscale. If you need a single WireGuard endpoint with no control plane at all, a plain WireGuard VPN server has fewer moving parts.

Questions

Which ports have to be open on the NetBird server?

TCP 80 and 443, and UDP 3478. Port 443 carries the dashboard, the management and signal gRPC services and the relay over WebSocket, behind Traefik; 3478/udp is STUN, which cannot go through an HTTP proxy. Add UDP 51820 only if you enable the optional NetBird Proxy service. This install does not serve the old pre-v0.29 ports, so upgrade any older clients.

Is an external identity provider required?

No. Current installs include an embedded Dex server, so you create local users in the dashboard. Google, Microsoft, Okta, Keycloak, authentik or any OIDC provider can be added alongside local users under Settings, Identity Providers.

Does all VPN traffic go through my server?

No. Peers try a direct WireGuard connection first, using STUN and the signal service to find each other. Only pairs that cannot traverse their NAT fall back to the relay, and their traffic then crosses the server, which is where an unmetered port helps.

Run your own NetBird control plane

A whole machine with root, a console on the racked servers and the m510 and m710x cartridges, and an unmetered port for relay traffic.

See the machines

Related on Server Room

Other use cases