Setting up Netbird with Zitadel on NixOS
2025-08-28 · 3 minute read
Preface
Deploying Netbird's management server on NixOS proved particularly difficult since no step-by-step guide was provided for NixOS. I had to piece things together by reading documentation from many different documentation sources, including source code, and experimenting.
Since the options at search.nixos.org were well documented, I was able to get a working setup after some trial and error, and debugging.
Introduction
NetBird is an open-source VPN management platform built on top of WireGuard making it easy to create secure private networks for your organization or home.
Setup
Netbird requires an Identity Provider for authentication/authorization. The supported self-hosted options are:
- Zitadel
- Keycloak
- Authentik
Zitadel
Configuration
Here's an example config for Zitadel, along with its database:
let
domain = "example.com";
in
{
services.zitadel = {
enable = true;
openFirewall = true;
masterKeyFile = "/path/to/zitadel/master_key";
extraStepsPaths = [ "/path/to/zitadel/admin_steps" ];
extraSettingsPaths = [ "/path/to/zitadel/settings" ];
tlsMode = "external";
settings = {
Port = 39995;
ExternalPort = 443;
ExternalDomain = "auth.${domain}";
Database = {
postgres = {
Host = "127.0.0.1";
Port = 5432;
Database = "zitadel";
MaxOpenConns = 15;
MaxIdleConns = 10;
MaxConnLifetime = "1h";
MaxConnIdleTime = "5m";
};
};
};
};
# Postgres database for Zitadel
virtualisation.oci-containers.containers.zitadel-db = {
image = "postgres:17";
ports = [ "5432:5432" ];
environmentFiles = [
"/path/to/zitadel/postgres_env"
];
volumes = [
"/var/lib/zitadel-db:/var/lib/postgresql/data"
];
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
# Ensure the mounted directory for the database exists
system.activationScripts.makeZitadelDir = lib.stringAfter [ "var" ] ''
mkdir -p /var/lib/zitadel-db
'';
# Proxy the SSO provider
services.nginx.enable = true;
services.nginx.virtualHosts."auth.${domain}" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:39995";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
'';
};
};
security.acme = {
acceptTerms = true;
defaults.email = "you@example.com";
};
}
Environment variables
Generate the master_key
with:
tr -dc A-Za-z0-9 </dev/urandom | head -c 32
Create and populate the admin_steps
file:
FirstInstance:
InstanceName: Zitadel
Org:
Human:
UserName: John
FirstName: John
LastName: Lackland
DisplayName: John
Password: make-sure-this-is-secure
PasswordChangeRequired: false
Email:
Address: you@example.com
Verified: true
Create and populate the settings
file:
Database:
postgres:
User:
Username: zitadel
Password: make-sure-this-is-secure
SSL:
Mode: disable
Admin:
Username: postgres
Password: make-sure-this-is-secure
SSL:
Mode: disable
The
postgres
user is used as a oneshot to initialize thezitadel
user after the initial database creation.
Create and populate the postgres_env
file:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=set-the-same-password-as-in-settings
POSTGRES_DB=postgres
Registration
Afterward, log in with your Admin
account at auth.example.com
, and follow Netbird's documentation on how to configure Zitadel here.
Make sure to put
netbird.example.com
as the domain in the Redirect Settings and notauth.example.com
.
Put the ClientSecret
from Zitadel in the client_secret
file, and hold on to the Client ID
.
The
Client ID
is not a secret so it's okay to hardcode it in the NixOS configuration.
Netbird
Afterward, set up Netbird:
{ config, lib, ... }:
let
domain = "example.com";
netbirdDomain = "netbird.${domain}";
clientId = "<YOUR_CLIENT_ID_FROM_ZITADEL>";
in
{
imports = [ ./zitadel.nix ];
services.netbird.server = {
enable = true;
enableNginx = true;
domain = netbirdDomain;
coturn = {
enable = true;
domain = netbirdDomain;
passwordFile = "/path/to/netbird/turn_password";
};
signal = {
enable = true;
enableNginx = true;
domain = netbirdDomain;
};
dashboard = {
enable = true;
enableNginx = true;
domain = netbirdDomain;
settings = {
AUTH_AUTHORITY = "https://auth.${domain}";
AUTH_CLIENT_ID = clientId;
AUTH_AUDIENCE = clientId;
};
};
management = {
enable = true;
enableNginx = true;
domain = netbirdDomain;
turnDomain = netbirdDomain;
singleAccountModeDomain = netbirdDomain;
oidcConfigEndpoint = "https://auth.${domain}/.well-known/openid-configuration";
settings = {
Signal.URI = "${netbirdDomain}:443";
HttpConfig.AuthAudience = clientId;
IdpManagerConfig.ClientConfig.ClientID = clientId;
DeviceAuthorizationFlow.ProviderConfig = {
Audience = clientId;
ClientID = clientId;
};
PKCEAuthorizationFlow.ProviderConfig = {
Audience = clientId;
ClientID = clientId;
};
TURNConfig = {
Secret._secret = "/path/to/netbird/turn_password";
CredentialsTTL = "12h";
TimeBasedCredentials = false;
Turns = [
{
Password._secret = "/path/to/netbird/turn_password";
Proto = "udp";
URI = "turn:${netbirdDomain}:3478";
Username = "netbird";
}
];
};
Relay = {
Addresses = [ "rels://${netbirdDomain}:33080" ];
CredentialsTTL = "24h";
Secret._secret = "/path/to/netbird/relay_secret";
};
DataStoreEncryptionKey._secret = "/path/to/netbird/data_store_encryption_key";
};
};
};
# Make the env available to the systemd service
systemd.services.netbird-management.serviceConfig = {
EnvironmentFile = "/path/to/netbird/setup.env";
};
# Override ACME settings to get a cert
services.nginx.virtualHosts = lib.mkMerge [
{
"${netbirdDomain}" = {
enableACME = true;
forceSSL = true;
};
}
];
# Run the Netbird relay with TLS to allow relaying over TCP
virtualisation.oci-containers.containers.netbird-relay = {
image = "netbirdio/relay:latest";
ports = [
"33080:33080"
];
volumes = [
"/var/lib/acme/${netbirdDomain}/:/certs:ro"
];
environment = {
NB_LOG_LEVEL = "info";
NB_LISTEN_ADDRESS = ":33080";
NB_EXPOSED_ADDRESS = "rels://${netbirdDomain}:33080";
NB_TLS_CERT_FILE = "/certs/fullchain.pem";
NB_TLS_KEY_FILE = "/certs/key.pem";
};
environmentFiles = [
"/path/to/netbird/relay_secret_container"
];
};
networking.firewall.allowedTCPPorts = [ 80 443 3478 10000 33080 ];
networking.firewall.allowedUDPPorts = [ 3478 5349 33080 ];
networking.firewall.allowedUDPPortRanges = [{
from = 40000;
to = 40050;
}]; # TURN ports
}
Environment files
Generate the turn_password
and data_store_encryption_key
with:
openssl rand -base64 32
Generate the relay_secret
with:
openssl rand -base64 32 | sed 's/=//g'
And also put it in the relay_secret_container
file for the Podman container:
NB_AUTH_SECRET=the-same-secret-as-in-relay-secret
Create and populate the setup.env
file:
NETBIRD_AUTH_OIDC_CONFIGURATION_ENDPOINT="https://auth.example.com/.well-known/openid-configuration"
NETBIRD_USE_AUTH0=false # Since we're using Zitadel
NETBIRD_AUTH_CLIENT_ID="<YOUR_CLIENT_ID_FROM_ZITADEL>"
NETBIRD_AUTH_SUPPORTED_SCOPES="openid profile email offline_access api"
NETBIRD_AUTH_AUDIENCE="<YOUR_CLIENT_ID_FROM_ZITADEL>"
NETBIRD_AUTH_REDIRECT_URI="/auth"
NETBIRD_AUTH_SILENT_REDIRECT_URI="/silent-auth"
NETBIRD_AUTH_DEVICE_AUTH_PROVIDER="hosted"
NETBIRD_AUTH_DEVICE_AUTH_CLIENT_ID="<YOUR_CLIENT_ID_FROM_ZITADEL>"
NETBIRD_MGMT_IDP="zitadel"
NETBIRD_IDP_MGMT_CLIENT_ID="netbird"
NETBIRD_IDP_MGMT_CLIENT_SECRET="<YOUR_CLIENT_SECRET_FROM_ZITADEL>"
NETBIRD_IDP_MGMT_EXTRA_MANAGEMENT_ENDPOINT="https://netbird.example.com/management/v1"
NETBIRD_MGMT_IDP_SIGNKEY_REFRESH=true
NETBIRD_DOMAIN="netbird.example.com"
NETBIRD_DISABLE_LETSENCRYPT=true # Since Netbird is behind nginx
NETBIRD_MGMT_API_PORT=443
NETBIRD_SIGNAL_PORT=443
TURN_MIN_PORT=40000
TURN_MAX_PORT=40050
Cloud providers
Many cloud providers like Hetzner Cloud use stateless firewalls (since they're cheaper to run than SPI firewalls). These can interfere with Netbird's operation.
If your VPS provider uses a stateless firewall, you have to open up the required dynamic ports that Netbird uses in the cloud provider's UI.
To see which ports you need to open, run:
sudo cat /proc/sys/net/ipv4/ip_local_port_range
See more details here.
Additional configuration
To enable the Netbird client, set:
services.netbird.enable = true;
Troubleshooting
Setup issues
The generated configuration for the management interface is stored in /var/lib/netbird-mgmt/management.json
. Verify this file if you suspect the environment variables aren't being applied properly, or to check if you're correctly overriding the defaults with services.netbird.server.management.settings
.
You can check if the TURN/STUN and Relay servers are working properly with the online tester Trickle ICE. A simple guide is provided here by Netbird.
Client issues
Run netbird status -d
to check the details of the client as well as available relays. This also shows the connection status with other peers.
A more detailed description of diagnosing client issues can be found in Netbird's documentation.