Browse Source

netbird: add control-plane cookbook

Deploy the NetBird control plane as separate containers on Fedora CoreOS:
management, signal, relay and dashboard (netbirdio/*) plus a coturn STUN/TURN
server. This is the advanced (multi-container) self-hosted layout, so it can
be pointed at an external OIDC provider; no identity provider is bundled.

- Depends on postgresql (management store, via other/postgresql/netbird.sql)
  and traefik (TLS + HTTP/gRPC/WebSocket ingress on 443, via
  other/traefik/netbird.yaml). NetBird obtains no certificates of its own
  (LETSENCRYPT_DOMAIN=none); management, signal, dashboard and relay share the
  443 host name and Traefik dispatches by path prefix, gRPC over h2c.
- coturn is published directly (3478/udp+tcp and the TURN relay UDP range
  cannot go through an HTTP reverse proxy).
- Runs as a dedicated UID 10035 / GID 10000. The dashboard uses the rootful
  upstream image mapped to the non-root host UID via UIDMap/GIDMap (like
  nextcloud), in its own network namespace with the HTTP port published only on
  loopback for Traefik.
- Operator provides management.json, management.env, dashboard.env, relay.env
  and turnserver.conf; the target and units are gated with ConditionPathExists
  so a half-configured VM stays idle. Working-but-insecure examples ship under
  config/examples/ so make install and the tests run without operator secrets.
- Images pinned to arm64-capable tags (management/signal/relay 0.79.0,
  dashboard v2.92.0, coturn 4.6.2), verified for the aarch64 target.

Verified: make dryrun, make install (dev VM), make package, make -C cookbooks
package (all cookbooks), butane --strict, and make pytest on a clean Fedora
CoreOS VM (19 passed, 2 skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjEeBApAoL8Z7PZmBo7ekU
feat/netbird-cookbook
Nicolas Massé 3 days ago
parent
commit
c470c2cec3
  1. 21
      cookbooks/netbird/Makefile
  2. 138
      cookbooks/netbird/README.md
  3. 20
      cookbooks/netbird/config/examples/dashboard.env
  4. 7
      cookbooks/netbird/config/examples/management.env
  5. 80
      cookbooks/netbird/config/examples/management.json
  6. 16
      cookbooks/netbird/config/examples/relay.env
  7. 34
      cookbooks/netbird/config/examples/turnserver.conf
  8. 38
      cookbooks/netbird/netbird-coturn.container
  9. 12
      cookbooks/netbird/netbird-coturn.image
  10. 47
      cookbooks/netbird/netbird-dashboard.container
  11. 12
      cookbooks/netbird/netbird-dashboard.image
  12. 55
      cookbooks/netbird/netbird-management.container
  13. 13
      cookbooks/netbird/netbird-management.image
  14. 37
      cookbooks/netbird/netbird-relay.container
  15. 12
      cookbooks/netbird/netbird-relay.image
  16. 37
      cookbooks/netbird/netbird-signal.container
  17. 12
      cookbooks/netbird/netbird-signal.image
  18. 22
      cookbooks/netbird/netbird.target
  19. 8
      cookbooks/netbird/other/postgresql/netbird.sql
  20. 102
      cookbooks/netbird/other/traefik/netbird.yaml
  21. 9
      cookbooks/netbird/overlay.bu
  22. 67
      cookbooks/netbird/tests/helpers.py
  23. 143
      cookbooks/netbird/tests/test_01_install.py
  24. 5
      cookbooks/netbird/tmpfiles.d/netbird.conf

21
cookbooks/netbird/Makefile

@ -0,0 +1,21 @@
##
## Makefile for NetBird control plane quadlet
##
DEPENDENCIES = postgresql traefik
# NetBird runs as UID 10035 (netbird) / GID 10000 (itix-svc) on the host.
PROJECT_UID = 10035
PROJECT_GID = 10000
# Include common Makefile
include ../../scripts/common.mk
# The example management.json and turnserver.conf carry secrets (data-store encryption
# key, TURN password, relay secret) and are mounted read-only into the containers, which
# run as UID 10035. Install them 0640 owned by 10035:10000 instead of the default 0644,
# so they are readable by the container user but not world-readable. The *.env files are
# handled by common.mk (root:root 0600, read by systemd as EnvironmentFile).
TARGET_NETBIRD_SECRET_EXAMPLES = $(patsubst config/examples/%, $(TARGET_CHROOT)/etc/quadlets/netbird/%, $(wildcard config/examples/management.json config/examples/turnserver.conf))
$(TARGET_NETBIRD_SECRET_EXAMPLES): $(TARGET_CHROOT)/etc/quadlets/netbird/%: config/examples/% $(TARGET_CHROOT)/etc/quadlets/netbird
install -m 0640 -o $(PROJECT_UID) -g $(PROJECT_GID) $< $@

138
cookbooks/netbird/README.md

@ -0,0 +1,138 @@
# NetBird (control plane)
[NetBird](https://netbird.io/) is an open-source, self-hostable WireGuard-based overlay
network. This cookbook deploys the **control plane** as separate containers, following the
advanced (multi-container) self-hosted layout, so it can be pointed at an **external OpenID
Connect identity provider**.
Components:
| Container | Image | Role |
|-----------|-------|------|
| `netbird-management` | `netbirdio/management` | Management API + gRPC (peer configuration, store) |
| `netbird-signal` | `netbirdio/signal` | Signal exchange (peer connection setup) |
| `netbird-relay` | `netbirdio/relay` | Relay for peers that cannot connect directly |
| `netbird-dashboard` | `netbirdio/dashboard` | Web UI (SPA) |
| `netbird-coturn` | `coturn/coturn` | STUN/TURN server (NAT traversal) |
> **Scope.** This cookbook is the control plane only. The NetBird **agent** (peers, routing
> peers) is out of scope. It does **not** bundle an identity provider: the management service
> and the dashboard point at an external OIDC provider that the operator configures.
## Prerequisites
### Dependencies (installed automatically)
- **`postgresql`** — the management store. The `other/postgresql/netbird.sql` hook creates
the `netbird` database and user. PostgreSQL handles major upgrades and backups.
- **`traefik`** — TLS termination and HTTP/gRPC/WebSocket ingress on port 443. The
`other/traefik/netbird.yaml` hook wires the routing. NetBird must **not** obtain its own
certificates (`LETSENCRYPT_DOMAIN=none`); Traefik owns TLS.
The `keycloak` cookbook of this repository is **not** a dependency (that would force it onto
every install). It is one valid way to provide the external OIDC provider — see below.
### virtiofs mounts
- `/var/lib/virtiofs/data/netbird` — precious data (the management data directory, which
holds key material peers pin). The management **store** itself lives in PostgreSQL.
### Resources (control plane, small deployment)
- **CPU:** 2 vCPU.
- **RAM:** ~1 GiB for the NetBird containers (PostgreSQL and Traefik are extra; budget ~2 GiB
total on the VM).
- **Disk:** a few hundred MiB for images; the store grows with the number of peers (tens of
MiB for hundreds of peers). coturn relays media through RAM/network, not disk.
## Files the operator must provide
All live under `/etc/quadlets/netbird/`. Working-but-insecure examples ship under
`config/examples/` (installed by `make install` and by the dev VM, **not** part of the
production package). `netbird.target` and each unit are gated with `ConditionPathExists`, so a
VM built from the ignition file before these are pushed stays idle instead of crash-looping.
| File | Mode / owner | Contents |
|------|--------------|----------|
| `management.json` | `0640` `netbird:itix-svc` | Management config: OIDC, STUN/TURN, relay, signal, data-store encryption key, store engine. Keystone that gates the target. |
| `management.env` | `0600` `root:root` | `NETBIRD_STORE_ENGINE_POSTGRES_DSN` (PostgreSQL DSN incl. DB password). |
| `dashboard.env` | `0600` `root:root` | Dashboard OIDC client + `NETBIRD_MGMT_API_ENDPOINT` + `LETSENCRYPT_DOMAIN=none`. |
| `relay.env` | `0600` `root:root` | `NB_LISTEN_ADDRESS`, `NB_EXPOSED_ADDRESS`, `NB_AUTH_SECRET`. |
| `turnserver.conf` | `0640` `netbird:itix-svc` | coturn config: TURN user/password, realm, and (behind NAT) `external-ip`. |
Values that must stay consistent across files:
- **DB password:** `management.env` DSN ↔ `other/postgresql/netbird.sql`.
- **TURN password:** `turnserver.conf` `user=self:<pw>``management.json` `TURNConfig.Turns[].Password`.
- **Relay secret:** `relay.env` `NB_AUTH_SECRET``management.json` `Relay.Secret`.
- **Data-store encryption key:** `management.json` `DataStoreEncryptionKey` — 32 bytes,
base64-encoded (`openssl rand -base64 32`). Losing or changing it makes encrypted store
fields unreadable; treat it as precious.
### OIDC settings the operator must fill in
The control plane needs an external OIDC provider. The `keycloak` cookbook of this repository
is one way to provide it (create a realm, a public PKCE client for the dashboard, and — if you
want NetBird to manage users in the IdP — a confidential client); or point at any provider you
run elsewhere. Fill in:
- **`management.json``HttpConfig`:** `AuthIssuer` (issuer URL), `AuthAudience`,
`AuthKeysLocation` (JWKS URL), and `OIDCConfigEndpoint` (the provider's
`.well-known/openid-configuration`).
- **`management.json``PKCEAuthorizationFlow.ProviderConfig`:** `ClientID`, `Audience`,
`AuthorizationEndpoint`, `TokenEndpoint`, `Scope`, `RedirectURLs`.
- **`dashboard.env`:** `AUTH_AUTHORITY`, `AUTH_CLIENT_ID`, `AUTH_AUDIENCE`,
`AUTH_SUPPORTED_SCOPES` (and `AUTH_CLIENT_SECRET` if your client is confidential).
> The shipped example leaves `OIDCConfigEndpoint` **empty** on purpose: management then boots
> without contacting a provider (JWKS is fetched lazily), which keeps `make install` and the
> tests self-sufficient. **In production set `OIDCConfigEndpoint`** (or at least a correct
> `AuthKeysLocation`) to your provider, otherwise tokens cannot be validated.
>
> `IdpManagerConfig.ManagerType` is `none` in the example. Set it (e.g. `keycloak`) with IdP
> admin client credentials only if you want NetBird to manage users in the provider.
## Ports
The consumer maintains the Internet-facing firewall by hand. Only these must be reachable
**from the Internet**:
| Port | Proto | Where | Why |
|------|-------|-------|-----|
| 443 | TCP | Traefik | Dashboard, management API + gRPC, signal gRPC, relay — all multiplexed on one host name (NetBird 0.29+ shares 443 via HTTP/2; Traefik dispatches by path prefix, gRPC over h2c). |
| 3478 | UDP (and TCP) | coturn | STUN/TURN. **Cannot** go through an HTTP reverse proxy. |
| 49152–65535 | UDP | coturn | TURN relayed-media port range. **Cannot** go through an HTTP reverse proxy. Keep in sync with `min-port`/`max-port` in `turnserver.conf`. |
Backend ports used **behind Traefik / on the host only** (must **not** be exposed to the
Internet):
| Port | Proto | Service | Bind |
|------|-------|---------|------|
| 8080 | TCP | dashboard (nginx) | `127.0.0.1` only (published from the container namespace) |
| 33073 | TCP | management (HTTP API + gRPC, h2c) | host |
| 10000 | TCP | signal (gRPC + WebSocket proxy, h2c) | host |
| 33080 | TCP | relay (WebSocket) | host |
| 9090 / 9092 / 9093 | TCP | management / signal / relay Prometheus metrics | host |
| 9000 | TCP | relay health check | host |
## UID and GID
Runs as **UID 10035** (`netbird`) / **GID 10000** (`itix-svc`).
- `management`, `signal`, `relay` run as `10035:10000` under host networking.
- `coturn` runs as `10035:10000` under host networking with `CAP_NET_BIND_SERVICE` (to bind
the privileged port 3478), the same pattern as the `traefik` cookbook.
- `dashboard` uses the upstream rootful image, which serves the SPA with nginx on port 80 and
ships no configurable HTTP port. Rather than fork it, it runs in its own network namespace
(not host networking) so nginx keeps port 80 inside the container, and the port is published
only on `127.0.0.1:8080` for Traefik. The rootful image is mapped to the non-root host UID
`10035` via `UIDMap`/`GIDMap` (the same approach as the `nextcloud` cookbook), so no process
runs as root on the host.
## Notes
- The management schema is migrated by the management process itself on start, so there is no
separate init/migration unit.
- coturn behind NAT: set `external-ip` in `turnserver.conf` so relayed addresses are correct.
- TLS on coturn (5349) is disabled; NetBird uses STUN/TURN over 3478 with long-term
credentials. Enabling it would require certificates this cookbook does not manage.

20
cookbooks/netbird/config/examples/dashboard.env

@ -0,0 +1,20 @@
# Environment for the NetBird dashboard container (the SPA reads these at container start to
# build its runtime OIDC configuration). This example is insecure; the operator overrides
# the OIDC values in production.
# The public base URL of the management API/gRPC endpoint, i.e. the NetBird host name behind
# Traefik. Must be reachable by browsers.
NETBIRD_MGMT_API_ENDPOINT=https://netbird
# TLS is terminated by the traefik cookbook. "none" makes the dashboard image serve plain
# HTTP on port 80 (inside its namespace) and never attempt to obtain its own certificate.
LETSENCRYPT_DOMAIN=none
# OIDC client used by the browser (public client, PKCE). Point these at your provider; the
# keycloak cookbook of this repository is one way to provide them.
AUTH_AUTHORITY=https://idp.example.test/realms/netbird
AUTH_CLIENT_ID=netbird-client
AUTH_CLIENT_SECRET=
AUTH_AUDIENCE=netbird-client
AUTH_SUPPORTED_SCOPES=openid profile email offline_access
USE_AUTH0=false

7
cookbooks/netbird/config/examples/management.env

@ -0,0 +1,7 @@
# Environment for the NetBird management container (read by systemd, injected into the
# container). The PostgreSQL DSN is kept here rather than in management.json so the database
# password is not stored in a file that the container user can read.
#
# The password below MUST match the one created by other/postgresql/netbird.sql.
# This example is insecure; the operator overrides it in production.
NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=127.0.0.1 user=netbird password=netbird dbname=netbird port=5432 sslmode=disable

80
cookbooks/netbird/config/examples/management.json

@ -0,0 +1,80 @@
{
"Stuns": [
{
"Proto": "udp",
"URI": "stun:netbird:3478",
"Username": "",
"Password": null
}
],
"TURNConfig": {
"Turns": [
{
"Proto": "udp",
"URI": "turn:netbird:3478",
"Username": "self",
"Password": "ebfea0b4d8e2443952b950a2d6ac600e"
}
],
"CredentialsTTL": "12h",
"Secret": "ebfea0b4d8e2443952b950a2d6ac600e",
"TimeBasedCredentials": false
},
"Relay": {
"Addresses": ["rels://netbird:443/relay"],
"CredentialsTTL": "24h",
"Secret": "7JIlAKQH6IbK2tNftT8FM3FoH0cadUhiO/q/GubbxVM="
},
"Signal": {
"Proto": "https",
"URI": "netbird:443",
"Username": "",
"Password": null
},
"ReverseProxy": {
"TrustedHTTPProxies": [],
"TrustedHTTPProxiesCount": 0,
"TrustedPeers": []
},
"Datadir": "",
"DataStoreEncryptionKey": "Xd4UL2rB7VGLpERH9RW1mf8PeQz8/vporOvfzgtJBTM=",
"StoreConfig": {
"Engine": "postgres"
},
"HttpConfig": {
"Address": "0.0.0.0:33073",
"AuthIssuer": "https://idp.example.test/realms/netbird",
"AuthAudience": "netbird-client",
"AuthKeysLocation": "https://idp.example.test/realms/netbird/protocol/openid-connect/certs",
"AuthUserIDClaim": "",
"CertFile": "",
"CertKey": "",
"IdpSignKeyRefreshEnabled": false,
"OIDCConfigEndpoint": ""
},
"IdpManagerConfig": {
"ManagerType": "none"
},
"DeviceAuthorizationFlow": {
"Provider": "none",
"ProviderConfig": {
"Audience": "netbird-client",
"ClientID": "netbird-client",
"Scope": "openid",
"UseIDToken": false
}
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"Audience": "netbird-client",
"ClientID": "netbird-client",
"ClientSecret": "",
"Domain": "",
"AuthorizationEndpoint": "https://idp.example.test/realms/netbird/protocol/openid-connect/auth",
"TokenEndpoint": "https://idp.example.test/realms/netbird/protocol/openid-connect/token",
"Scope": "openid profile email offline_access",
"RedirectURLs": ["http://localhost:53000"],
"UseIDToken": false
}
}
}

16
cookbooks/netbird/config/examples/relay.env

@ -0,0 +1,16 @@
# Environment for the NetBird relay container. This example is insecure; the operator
# overrides NB_AUTH_SECRET in production.
NB_LOG_LEVEL=info
# The relay listens on 33080 (plaintext WebSocket); Traefik terminates TLS on 443 and routes
# the /relay path prefix here.
NB_LISTEN_ADDRESS=:33080
# The address peers are told to use to reach the relay, through Traefik on 443 (rels:// is
# relay-over-TLS). Set this to your NetBird host name.
NB_EXPOSED_ADDRESS=rels://netbird:443/relay
# Shared secret between the relay and the management service. MUST match the "Secret" field
# of the "Relay" block in management.json.
NB_AUTH_SECRET=7JIlAKQH6IbK2tNftT8FM3FoH0cadUhiO/q/GubbxVM=

34
cookbooks/netbird/config/examples/turnserver.conf

@ -0,0 +1,34 @@
# coturn configuration for NetBird (operator-provided). This example is insecure; the
# operator overrides the credentials and sets external-ip in production.
#
# TLS (5349) is intentionally disabled: NetBird uses STUN/TURN over 3478 and the payloads
# are already authenticated by the long-term credential mechanism. Enabling TLS would
# require certificates, which this cookbook does not manage.
# STUN/TURN listener (UDP + TCP).
listening-port=3478
no-tls
no-dtls
# UDP relay port range for relayed media. These, together with 3478/udp, are the only
# NetBird ports that must be reachable directly from the Internet (they cannot go through
# an HTTP reverse proxy). Keep this range in sync with the firewall.
min-port=49152
max-port=65535
# Long-term credentials. The user/password MUST match the "TURNConfig.Turns" entry in
# management.json.
fingerprint
lt-cred-mech
user=self:ebfea0b4d8e2443952b950a2d6ac600e
realm=netbird
# Behind NAT, uncomment and set the public IP so coturn advertises the correct relayed
# address (XOR-RELAYED-ADDRESS). On a host with a public IP directly, coturn auto-detects it.
#external-ip=203.0.113.10
# Logging and runtime.
log-file=stdout
no-software-attribute
no-cli
pidfile=/var/tmp/turnserver.pid

38
cookbooks/netbird/netbird-coturn.container

@ -0,0 +1,38 @@
[Unit]
Description=NetBird coturn STUN/TURN Server
Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide
After=network.target
# turnserver.conf carries the TURN user/password (must match management.json) and the realm.
ConditionPathExists=/etc/quadlets/netbird/turnserver.conf
# Start/stop this unit when the target is started/stopped
PartOf=netbird.target
[Container]
ContainerName=netbird-coturn
Image=netbird-coturn.image
AutoUpdate=registry
# coturn cannot go behind an HTTP reverse proxy: it binds 3478/udp+tcp for STUN/TURN and a
# wide UDP range for relayed media, so it uses host networking and is published directly to
# the Internet (see the README ports section). It runs as UID 10035 (no UIDMap, like Traefik)
# with CAP_NET_BIND_SERVICE so the unprivileged user can bind the privileged port 3478.
User=10035
Group=10000
Network=host
AddCapability=CAP_NET_BIND_SERVICE
# turnserver.conf is operator-provided (TURN credentials, realm, and — behind NAT — the
# external-ip). The image entrypoint (docker-entrypoint.sh) runs turnserver with these args.
Volume=/etc/quadlets/netbird/turnserver.conf:/etc/turnserver.conf:ro,Z
Exec=-c /etc/turnserver.conf
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=120
TimeoutStopSec=30
[Install]
WantedBy=netbird.target

12
cookbooks/netbird/netbird-coturn.image

@ -0,0 +1,12 @@
[Unit]
Description=podman pull docker.io/coturn/coturn
Documentation=https://hub.docker.com/r/coturn/coturn
# Only pull once NetBird has been configured.
ConditionPathExists=/etc/quadlets/netbird/turnserver.conf
[Image]
# The "4.6.2" tag is a multi-architecture manifest (amd64 + arm64 + arm/v6 + arm/v7 + more,
# checked 2026-09-19), which is required to run on the aarch64 target.
# arm64 digest: sha256:56355c9c24b314c8f9e9ac00965144e0eaf33ea732a4567e4d911c86b281f9a8
Image=docker.io/coturn/coturn:4.6.2

47
cookbooks/netbird/netbird-dashboard.container

@ -0,0 +1,47 @@
[Unit]
Description=NetBird Dashboard
Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide
After=network.target
# dashboard.env carries the OIDC client settings and the management API endpoint.
ConditionPathExists=/etc/quadlets/netbird/dashboard.env
# Start/stop this unit when the target is started/stopped
PartOf=netbird.target
[Container]
ContainerName=netbird-dashboard
Image=netbird-dashboard.image
AutoUpdate=registry
# The upstream dashboard image serves the SPA with nginx bound to port 80 as root, and it
# ships no configurable HTTP port (only the CSP-patching init script relies on the built-in
# default.conf). Rather than fork the image, it runs in its own network namespace (NOT host
# networking) so nginx keeps port 80 inside the container, and the port is published only on
# loopback for Traefik. As there is no rootless tag with a stable semver, the rootful image
# is mapped to the non-root host UID 10035 via UIDMap/GIDMap (same approach as nextcloud).
PublishPort=127.0.0.1:8080:80
UIDMap=0:1000000:65535
UIDMap=+0:10035:1
GIDMap=0:1000000:65535
GIDMap=+0:10000:1
# All dashboard settings (OIDC client, management endpoint, LETSENCRYPT_DOMAIN=none so the
# image serves plain HTTP and never tries to obtain its own certificate) come from here.
EnvironmentFile=/etc/quadlets/netbird/dashboard.env
# Health check: nginx serves the SPA on port 80 inside the container namespace.
HealthCmd=wget -q -O /dev/null http://127.0.0.1:80/
HealthInterval=30s
HealthTimeout=10s
HealthStartPeriod=15s
HealthRetries=3
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=120
TimeoutStopSec=30
[Install]
WantedBy=netbird.target

12
cookbooks/netbird/netbird-dashboard.image

@ -0,0 +1,12 @@
[Unit]
Description=podman pull docker.io/netbirdio/dashboard
Documentation=https://hub.docker.com/r/netbirdio/dashboard
# Only pull once NetBird has been configured.
ConditionPathExists=/etc/quadlets/netbird/dashboard.env
[Image]
# The "v2.92.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked
# 2026-09-19), which is required to run on the aarch64 target.
# arm64 digest: sha256:c2f9a1251e069d50b67dc8aa40821ce890d6aa09e1e1876434cb70892bd78e3f
Image=docker.io/netbirdio/dashboard:v2.92.0

55
cookbooks/netbird/netbird-management.container

@ -0,0 +1,55 @@
[Unit]
Description=NetBird Management Service
Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide
After=network.target var-lib-virtiofs-data.mount postgresql.target
Requires=var-lib-virtiofs-data.mount
Wants=postgresql.target
# Only start once NetBird has been configured. management.json carries the OIDC settings,
# the TURN/relay secrets and the data-store encryption key; management.env carries the
# PostgreSQL DSN (with the database password). Half-configured must not start.
ConditionPathExists=/etc/quadlets/netbird/management.json
ConditionPathExists=/etc/quadlets/netbird/management.env
# Start/stop this unit when the target is started/stopped
PartOf=netbird.target
[Container]
ContainerName=netbird-management
Image=netbird-management.image
AutoUpdate=registry
# No need for root privileges: the management server binds 33073 (> 1024) as UID 10035.
# It reaches PostgreSQL over host networking on 127.0.0.1:5432, which is why it does not
# use a separate network namespace (the loopback convention across this repository).
User=10035
Group=10000
Network=host
# PostgreSQL DSN (NETBIRD_STORE_ENGINE_POSTGRES_DSN) is injected here so the password is
# not baked into the world-readable management.json.
EnvironmentFile=/etc/quadlets/netbird/management.env
# Configuration (read-only) and the management data directory (precious: it holds key
# material that peers pin, so it lives on the virtiofs mount).
Volume=/etc/quadlets/netbird/management.json:/etc/netbird/management.json:ro,Z
Volume=/var/lib/virtiofs/data/netbird/management:/var/lib/netbird:z
# The image entrypoint is "netbird-mgmt management"; Exec provides the arguments.
# Management serves the HTTP API and gRPC (ManagementService) on the SAME port 33073 via
# HTTP/2; Traefik reaches it on 127.0.0.1:33073 (h2c for gRPC, http for /api). The store
# engine is set to postgres in management.json; the schema is migrated by the management
# process itself on start, so no separate init unit is required.
Exec=--port 33073 --log-file console --log-level info --metrics-port 9090 --dns-domain netbird.selfhosted --single-account-mode-domain netbird.selfhosted
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=120
TimeoutStopSec=30
# Wait for PostgreSQL to accept connections on localhost before starting.
ExecStartPre=/bin/sh -c 'exec 2>/dev/null; for try in $(seq 0 12); do if ! /bin/true 5<> /dev/tcp/127.0.0.1/5432; then echo "Waiting for PostgreSQL to be available..."; sleep 5; else exit 0; fi; done; exit 1'
[Install]
WantedBy=netbird.target

13
cookbooks/netbird/netbird-management.image

@ -0,0 +1,13 @@
[Unit]
Description=podman pull docker.io/netbirdio/management
Documentation=https://hub.docker.com/r/netbirdio/management
# Only pull once NetBird has been configured.
ConditionPathExists=/etc/quadlets/netbird/management.json
ConditionPathExists=/etc/quadlets/netbird/management.env
[Image]
# The ":0.79.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked
# 2026-09-19), which is required to run on the aarch64 target.
# arm64 digest: sha256:175b3d7879688d55c1229817508e5291676fcfe979cd52789ea6f618e7dbd82c
Image=docker.io/netbirdio/management:0.79.0

37
cookbooks/netbird/netbird-relay.container

@ -0,0 +1,37 @@
[Unit]
Description=NetBird Relay Service
Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide
After=network.target
# relay.env carries the relay auth secret and the exposed address; do not start without it.
ConditionPathExists=/etc/quadlets/netbird/relay.env
# Start/stop this unit when the target is started/stopped
PartOf=netbird.target
[Container]
ContainerName=netbird-relay
Image=netbird-relay.image
AutoUpdate=registry
# No need for root privileges: relay binds 33080 (> 1024) as UID 10035. The embedded STUN
# server is off by default (--enable-stun), so relay does not contend with coturn on 3478.
User=10035
Group=10000
Network=host
# NB_LOG_LEVEL, NB_LISTEN_ADDRESS, NB_EXPOSED_ADDRESS and NB_AUTH_SECRET are provided here.
# The relay speaks WebSocket (rels://); Traefik terminates TLS on 443, routes the /relay
# PathPrefix to 127.0.0.1:33080 and upgrades the connection. The metrics port is moved off
# the default 9090 to avoid colliding with management/signal under host networking.
EnvironmentFile=/etc/quadlets/netbird/relay.env
Exec=--metrics-port 9093
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=120
TimeoutStopSec=30
[Install]
WantedBy=netbird.target

12
cookbooks/netbird/netbird-relay.image

@ -0,0 +1,12 @@
[Unit]
Description=podman pull docker.io/netbirdio/relay
Documentation=https://hub.docker.com/r/netbirdio/relay
# Only pull once NetBird has been configured.
ConditionPathExists=/etc/quadlets/netbird/relay.env
[Image]
# The ":0.79.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked
# 2026-09-19), which is required to run on the aarch64 target.
# arm64 digest: sha256:80a94d8d174cc74f5bcf77e4b517e1f1c6ac8e31428cdf6f06a204e1eb2afade
Image=docker.io/netbirdio/relay:0.79.0

37
cookbooks/netbird/netbird-signal.container

@ -0,0 +1,37 @@
[Unit]
Description=NetBird Signal Service
Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide
After=network.target
# Signal takes no configuration file of its own, but it must not run before the operator
# has configured NetBird (management.json is the keystone).
ConditionPathExists=/etc/quadlets/netbird/management.json
# Start/stop this unit when the target is started/stopped
PartOf=netbird.target
[Container]
ContainerName=netbird-signal
Image=netbird-signal.image
AutoUpdate=registry
# No need for root privileges: signal binds 10000 (> 1024) as UID 10035.
User=10035
Group=10000
Network=host
# The image entrypoint is "netbird-signal run"; Exec provides the arguments. Signal serves
# gRPC (SignalExchange) and the WebSocket proxy on a SINGLE plaintext port 10000; Traefik
# terminates TLS on 443 and reaches it on 127.0.0.1:10000 (h2c for gRPC, http for the
# /ws-proxy/signal upgrade). The metrics port is moved off the default 9090 to avoid
# colliding with the management metrics endpoint under host networking.
Exec=--port 10000 --log-file console --log-level info --metrics-port 9092
[Service]
Restart=always
RestartSec=10
TimeoutStartSec=120
TimeoutStopSec=30
[Install]
WantedBy=netbird.target

12
cookbooks/netbird/netbird-signal.image

@ -0,0 +1,12 @@
[Unit]
Description=podman pull docker.io/netbirdio/signal
Documentation=https://hub.docker.com/r/netbirdio/signal
# Only pull once NetBird has been configured.
ConditionPathExists=/etc/quadlets/netbird/management.json
[Image]
# The ":0.79.0" tag is a multi-architecture manifest (amd64 + arm64 + arm/v7, checked
# 2026-09-19), which is required to run on the aarch64 target.
# arm64 digest: sha256:78fedec72dfdae9a9527d20254d1a6943d88d27689b741db31d4c9ded4efd433
Image=docker.io/netbirdio/signal:0.79.0

22
cookbooks/netbird/netbird.target

@ -0,0 +1,22 @@
[Unit]
Description=NetBird Control Plane Target
Documentation=https://docs.netbird.io/selfhosted/selfhosted-guide
# The control plane needs PostgreSQL (management store) and Traefik (TLS + HTTP/gRPC
# ingress on 443). coturn is published directly and does not go through Traefik.
Requires=postgresql.target traefik.target
After=postgresql.target traefik.target
Requires=netbird-management.service netbird-signal.service netbird-relay.service netbird-dashboard.service netbird-coturn.service
After=netbird-management.service netbird-signal.service netbird-relay.service netbird-dashboard.service netbird-coturn.service
# Allow isolation - can stop/start this target independently
AllowIsolate=yes
# Only start once NetBird has been configured (the operator provides management.json).
# A VM built from the ignition file before the secrets are pushed stays idle instead of
# crash-looping.
ConditionPathExists=/etc/quadlets/netbird/management.json
[Install]
WantedBy=multi-user.target

8
cookbooks/netbird/other/postgresql/netbird.sql

@ -0,0 +1,8 @@
-- Initialization script for the NetBird management database and user.
-- The password here MUST match the one in the PostgreSQL DSN of management.env
-- (NETBIRD_STORE_ENGINE_POSTGRES_DSN). This example ships an insecure password; the
-- operator overrides both in production.
CREATE USER netbird WITH PASSWORD 'netbird';
CREATE DATABASE netbird OWNER netbird;
GRANT ALL PRIVILEGES ON DATABASE netbird TO netbird;
ALTER ROLE netbird SET client_encoding TO 'utf8';

102
cookbooks/netbird/other/traefik/netbird.yaml

@ -0,0 +1,102 @@
# Traefik dynamic configuration contributed by the netbird cookbook.
#
# NetBird 0.29+ multiplexes the management API, the management gRPC service and the signal
# gRPC service onto a single host name on port 443. TLS is terminated HERE (on the https /
# :443 entry point) and requests are dispatched by path prefix to the right backend on
# loopback. gRPC backends are reached over h2c (HTTP/2 cleartext), the plain HTTP and the
# WebSocket-proxy endpoints over http/1.1. coturn is NOT here: it is published directly.
#
# In production the host name and the certificate resolver are site values, injected by the
# operator via an overlay. This shipped fragment uses the bare host `netbird` and a
# self-signed certificate (bare `tls: {}`) so it is testable as-is; uncomment certResolver
# to obtain a real Let's Encrypt certificate.
http:
routers:
# Catch-all for the SPA and its static assets. Lowest priority so the path-prefix
# routers below win.
netbird-dashboard:
rule: "Host(`netbird`)"
entryPoints:
- https
service: netbird-dashboard
priority: 1
tls: {}
#tls:
# certResolver: le
# Management REST API.
netbird-api:
rule: "Host(`netbird`) && PathPrefix(`/api`)"
entryPoints:
- https
service: netbird-management
tls: {}
# Management gRPC (ManagementService) — h2c to the backend.
netbird-management:
rule: "Host(`netbird`) && PathPrefix(`/management.ManagementService/`)"
entryPoints:
- https
service: netbird-management-grpc
tls: {}
# Management WebSocket proxy.
netbird-wsproxy-mgmt:
rule: "Host(`netbird`) && PathPrefix(`/ws-proxy/management`)"
entryPoints:
- https
service: netbird-management
tls: {}
# Signal gRPC (SignalExchange) — h2c to the backend.
netbird-signal:
rule: "Host(`netbird`) && PathPrefix(`/signalexchange.SignalExchange/`)"
entryPoints:
- https
service: netbird-signal-grpc
tls: {}
# Signal WebSocket proxy.
netbird-wsproxy-signal:
rule: "Host(`netbird`) && PathPrefix(`/ws-proxy/signal`)"
entryPoints:
- https
service: netbird-signal
tls: {}
# Relay (WebSocket, rels://).
netbird-relay:
rule: "Host(`netbird`) && PathPrefix(`/relay`)"
entryPoints:
- https
service: netbird-relay
tls: {}
services:
netbird-dashboard:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
netbird-management:
loadBalancer:
servers:
- url: "http://127.0.0.1:33073"
passHostHeader: true
netbird-management-grpc:
loadBalancer:
# h2c:// tells Traefik to speak HTTP/2 cleartext to the gRPC backend.
servers:
- url: "h2c://127.0.0.1:33073"
netbird-signal:
loadBalancer:
servers:
- url: "http://127.0.0.1:10000"
netbird-signal-grpc:
loadBalancer:
servers:
- url: "h2c://127.0.0.1:10000"
netbird-relay:
loadBalancer:
servers:
- url: "http://127.0.0.1:33080"
passHostHeader: true

9
cookbooks/netbird/overlay.bu

@ -0,0 +1,9 @@
variant: fcos
version: 1.4.0
passwd:
users:
- name: netbird
uid: 10035
gecos: NetBird control plane
home_dir: /var/lib/quadlets/netbird
primary_group: itix-svc

67
cookbooks/netbird/tests/helpers.py

@ -0,0 +1,67 @@
"""Shared expectations for the NetBird control-plane tests."""
import test_quadlet # noqa: F401
class TestNetbird(test_quadlet.TestQuadlet):
"""Common state checks for a freshly installed NetBird control plane."""
expected_services = [
{"name": "netbird.target", "state": "active", "exists": True},
{"name": "netbird-management.service", "state": "active", "exists": True},
{"name": "netbird-signal.service", "state": "active", "exists": True},
{"name": "netbird-relay.service", "state": "active", "exists": True},
{"name": "netbird-dashboard.service", "state": "active", "exists": True},
{"name": "netbird-coturn.service", "state": "active", "exists": True},
# Dependencies must be up too.
{"name": "postgresql.target", "state": "active", "exists": True},
{"name": "traefik.target", "state": "active", "exists": True},
]
expected_sockets = [
# Backend listeners on the host.
{"uri": "tcp://127.0.0.1:33073", "state": "listening"}, # management (HTTP + gRPC)
{"uri": "tcp://127.0.0.1:10000", "state": "listening"}, # signal (gRPC + ws-proxy)
{"uri": "tcp://127.0.0.1:33080", "state": "listening"}, # relay (WebSocket)
{"uri": "tcp://127.0.0.1:3478", "state": "listening"}, # coturn STUN/TURN
# Dashboard is published on loopback only.
{"uri": "tcp://127.0.0.1:8080", "state": "listening"},
# Traefik ingress.
{"uri": "tcp://127.0.0.1:443", "state": "listening"},
]
expected_ports = [
# Reachable from the outside: Traefik (443), coturn STUN/TURN (3478), SSH.
{"number": 443, "protocol": "tcp", "state": "open"},
{"number": 3478, "protocol": "tcp", "state": "open"},
{"number": 22, "protocol": "tcp", "state": "open"},
# The dashboard is published on loopback only: it must NOT be reachable from outside.
{"number": 8080, "protocol": "tcp", "state": "closed"},
]
expected_files = [
{"path": "/var/lib/quadlets/netbird", "type": "directory", "owner": "netbird", "group": "itix-svc"},
{"path": "/var/lib/virtiofs/data/netbird/management", "type": "directory", "owner": "netbird", "group": "itix-svc", "mode": 0o700},
{"path": "/etc/quadlets/netbird/management.json", "type": "file", "owner": "netbird", "group": "itix-svc", "mode": 0o640},
{"path": "/etc/quadlets/netbird/management.env", "type": "file", "owner": "root", "group": "root", "mode": 0o600},
]
expected_podman_images = [
{"name": "docker.io/netbirdio/management", "tag": "0.79.0", "state": "present"},
{"name": "docker.io/netbirdio/signal", "tag": "0.79.0", "state": "present"},
{"name": "docker.io/netbirdio/relay", "tag": "0.79.0", "state": "present"},
{"name": "docker.io/netbirdio/dashboard", "tag": "v2.92.0", "state": "present"},
{"name": "docker.io/coturn/coturn", "tag": "4.6.2", "state": "present"},
]
expected_podman_containers = [
{"name": "netbird-management", "state": "present", "pid1": {"owner": "10035", "group": "10000"}},
{"name": "netbird-signal", "state": "present", "pid1": {"owner": "10035", "group": "10000"}},
{"name": "netbird-relay", "state": "present", "pid1": {"owner": "10035", "group": "10000"}},
{"name": "netbird-coturn", "state": "present", "pid1": {"owner": "10035", "group": "10000"}},
# Dashboard uses the rootful image mapped to 10035 via UIDMap.
{"name": "netbird-dashboard", "state": "present", "pid1": {"owner": "10035", "group": "10000"}},
]
expected_main_service = "netbird.target"
expected_main_service_timeout = 300

143
cookbooks/netbird/tests/test_01_install.py

@ -0,0 +1,143 @@
"""Fresh-install tests for the NetBird control plane.
The test ignition (fcos-test.ign) contains the cookbook and its dependencies but NOT the
examples or the dependency hooks (both are excluded from the production package). So this
module injects, via ``fcos_extra_files``:
* the PostgreSQL server config (config.env) otherwise postgresql never starts and
management loops waiting for the database,
* the operator-provided NetBird config (read from this cookbook's own config/examples/),
* the PostgreSQL init hook that creates the netbird database (other/postgresql/netbird.sql),
* the Traefik routing hook (other/traefik/netbird.yaml) and a minimal Traefik main config.
The tests are self-sufficient: they assert what holds without a working OIDC provider (units
active, ports listening, generated config in place, database reachable, Traefik routing to the
right backend). They do not attempt a login flow.
"""
import subprocess
import textwrap
from pathlib import Path
import pytest
from helpers import TestNetbird
COOKBOOK_DIR = Path(__file__).resolve().parent.parent
# PostgreSQL server config for the dependency. config.env ships only as an example (excluded
# from the test ignition), so it must be injected or postgresql.target never comes up.
POSTGRESQL_CONFIG_ENV = textwrap.dedent("""\
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres
POSTGRES_HOST_AUTH_METHOD=scram-sha-256
POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256
POSTGRES_ARGS=-h 127.0.0.1
PGPORT=5432
PG_MAJOR=18
POSTGRES_BACKUP_RETENTION=7
""")
# Minimal Traefik main configuration: a file provider watching conf.d and the two entry
# points. TLS is served self-signed (the netbird.yaml routers use bare `tls: {}`), so no ACME
# / certificate resolver is needed for the tests.
TRAEFIK_YAML = """\
api:
dashboard: false
# The traefik cookbook ships a packaged conf.d/ping.yaml that references ping@internal;
# enable manualRouting so that router loads cleanly.
ping:
manualRouting: true
log:
level: INFO
accesslog: false
global:
sendanonymoususage: false
checknewversion: false
entryPoints:
http:
address: ":80"
https:
address: ":443"
providers:
file:
directory: /etc/traefik/conf.d/
watch: true
"""
def _read(rel: str) -> str:
return (COOKBOOK_DIR / rel).read_text()
@pytest.fixture(scope="package")
def fcos_vm_config() -> tuple[int, int, int, int]:
"""More resources: five NetBird images plus PostgreSQL and Traefik to pull and start."""
return (6144, 4, 50, 100)
@pytest.fixture(scope="module")
def fcos_extra_files() -> dict:
"""Inject operator config + dependency config/hooks that the test ignition does not carry."""
return {
# PostgreSQL server config (dependency): without it postgresql.target never starts.
"/etc/quadlets/postgresql/config.env": (POSTGRESQL_CONFIG_ENV, 0, 0, 0o600),
# Traefik main config + the routing hook shipped by this cookbook.
"/etc/quadlets/traefik/traefik.yaml": (TRAEFIK_YAML, 10001, 10000, 0o644),
"/etc/quadlets/traefik/conf.d/netbird.yaml": (_read("other/traefik/netbird.yaml"), 10001, 10000, 0o644),
# PostgreSQL init hook: creates the netbird database/user on first boot.
"/etc/quadlets/postgresql/init.d/netbird.sql": (_read("other/postgresql/netbird.sql"), 10004, 10000, 0o600),
# Operator-provided NetBird config (working-but-insecure examples).
"/etc/quadlets/netbird/management.json": (_read("config/examples/management.json"), 10035, 10000, 0o640),
"/etc/quadlets/netbird/management.env": (_read("config/examples/management.env"), 0, 0, 0o600),
"/etc/quadlets/netbird/dashboard.env": (_read("config/examples/dashboard.env"), 0, 0, 0o600),
"/etc/quadlets/netbird/relay.env": (_read("config/examples/relay.env"), 0, 0, 0o600),
"/etc/quadlets/netbird/turnserver.conf": (_read("config/examples/turnserver.conf"), 10035, 10000, 0o640),
}
class TestNetbirdInstall(TestNetbird):
"""Verify the NetBird control plane comes up cleanly on a fresh VM boot."""
def test_management_connected_to_postgres(self, fcos_host):
"""Management must have selected the Postgres store engine and not be crash-looping."""
result = fcos_host.run("podman logs netbird-management 2>&1 | grep -i 'Postgres store engine'")
assert result.rc == 0, "management did not report using the Postgres store engine"
restarts = fcos_host.run("systemctl show -p NRestarts --value netbird-management.service")
assert restarts.stdout.strip() == "0", f"management restarted {restarts.stdout.strip()} times"
def test_netbird_database_exists(self, fcos_host):
"""The postgresql hook must have created the netbird database."""
result = fcos_host.run(
"podman exec postgresql-server psql -U postgres -tAc "
"\"SELECT 1 FROM pg_database WHERE datname='netbird'\""
)
assert result.stdout.strip() == "1", f"netbird database missing: {result.stdout} {result.stderr}"
def test_dashboard_served_through_traefik(self, fcos_vm):
"""Traefik must serve the dashboard SPA over HTTPS (self-signed) on the netbird host."""
result = subprocess.run(
["curl", "-sS", "-k", "-o", "/dev/null", "-w", "%{http_code}",
"--resolve", f"netbird:443:{fcos_vm.ip}", "https://netbird/"],
check=False, capture_output=True, text=True,
)
assert result.stdout.strip() == "200", f"dashboard not served: got {result.stdout!r} {result.stderr}"
def test_management_api_reachable_through_traefik(self, fcos_vm):
"""Traefik must route /api to management (401 = reached and demands auth, not 502)."""
result = subprocess.run(
["curl", "-sS", "-k", "-o", "/dev/null", "-w", "%{http_code}",
"--resolve", f"netbird:443:{fcos_vm.ip}", "https://netbird/api/users"],
check=False, capture_output=True, text=True,
)
assert result.stdout.strip() == "401", f"/api not routed to management: got {result.stdout!r}"
def test_relay_reachable_through_traefik(self, fcos_vm):
"""Traefik must route /relay to the relay (426 = WebSocket upgrade required, not 502)."""
result = subprocess.run(
["curl", "-sS", "-k", "-o", "/dev/null", "-w", "%{http_code}",
"--resolve", f"netbird:443:{fcos_vm.ip}", "https://netbird/relay"],
check=False, capture_output=True, text=True,
)
assert result.stdout.strip() == "426", f"/relay not routed to relay: got {result.stdout!r}"

5
cookbooks/netbird/tmpfiles.d/netbird.conf

@ -0,0 +1,5 @@
# Precious data: the management data directory holds key material that peers pin, so it
# lives on the virtiofs mount and is picked up by the host's ZFS snapshots and backups.
# (The management store itself lives in PostgreSQL, handled by the postgresql cookbook.)
d$ /var/lib/virtiofs/data/netbird 0700 10035 10000 -
d$ /var/lib/virtiofs/data/netbird/management 0700 10035 10000 -
Loading…
Cancel
Save