Self-hosting NetBird on Kubernetes: A Zero-Trust VPN Replacement
NetBird runs a WireGuard mesh where peers connect directly when they can and fall back to a relay when they can’t. Access is governed by policy groups, not network topology. There’s no concept of “inside the network.”
Below is a full self-hosted deployment on Kubernetes: control plane via Helm, Keycloak as the OIDC identity provider, Traefik ingress, and cert-manager for TLS.
Architecture
NetBird’s control plane has four components, all deployable from a single Helm chart:
| Component | Role | Exposed via |
|---|---|---|
| Management | REST + gRPC API; peer registry, policy engine | Traefik ingress (HTTPS + gRPC) |
| Signal | WireGuard ICE/STUN signaling between peers | Traefik ingress (gRPC) |
| Dashboard | Web UI for admin and user self-service | Traefik ingress (HTTPS) |
| Relay | TURN-style relay for peers that can’t connect directly | Traefik ingress (HTTPS, websocket) |
Keycloak handles identity alongside the control plane. Peers authenticate with the Device Authorization Grant flow: the CLI prints a short code, you open it in a browser, log into Keycloak, and the CLI picks up the token. No client secret required.
For local development, nip.io maps *.netbird.192.168.50.57.nip.io to your machine IP without a real domain or DNS entry.
Prerequisites
Before deploying NetBird you need:
- A Kubernetes cluster with Traefik ingress installed
- cert-manager with a working
ClusterIssuer - Helm 3 installed locally
- Firewall rules that allow:
443/TCPinbound to your ingress IP (for management, signal, dashboard, relay)3478/UDPand49152-65535/UDPinbound if you use a standalone Coturn relay (not covered here — the embedded relay via Traefik avoids this requirement for most cases)
For local development, k3d + Colima works well. If you follow the k8s-infra pattern, deploy that first — it provides cert-manager, Traefik, and a private CA.
Phase 1 — NetBird Control Plane
Add the Helm repo
helm repo add netbird https://netbird.io/helm
helm repo update
Build your values file
Create netbird-values.yaml. The key sections are the management configmap (JSON), ingress for all three HTTP components, and relay configuration.
management:
enabled: true
podCommand:
args:
- --port=80
- --log-file=console
- --log-level=info
- --disable-anonymous-metrics=true
- --single-account-mode-domain=netbird.YOUR_IP.nip.io
- --dns-domain=nb.internal
configmap: |-
{
"Stuns": [
{
"Proto": "udp",
"URI": "stun:YOUR_IP:3478",
"Username": "",
"Password": ""
}
],
"TURNConfig": {
"TimeBasedCredentials": false,
"CredentialsTTL": "12h0m0s",
"Secret": "CHANGE_ME",
"Turns": [
{
"Proto": "udp",
"URI": "turn:YOUR_IP:3478",
"Username": "netbird",
"Password": "CHANGE_ME"
}
]
},
"Signal": {
"Proto": "https",
"URI": "signal.netbird.YOUR_IP.nip.io:443",
"Username": "",
"Password": ""
},
"Datadir": "/var/lib/netbird/",
"DataStoreEncryptionKey": "GENERATE_ME",
"HttpConfig": {
"LetsEncryptDomain": "",
"CertFile": "",
"CertKey": "",
"AuthAudience": "netbird",
"AuthIssuer": "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird",
"AuthKeysLocation": "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird/protocol/openid-connect/certs",
"OIDCConfigEndpoint": "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird/.well-known/openid-configuration",
"IdpSignKeyRefreshEnabled": true
},
"IdpManagerConfig": {
"ManagerType": "none"
},
"DeviceAuthorizationFlow": {
"Provider": "hosted",
"ProviderConfig": {
"ClientID": "netbird",
"ClientSecret": "",
"Domain": "keycloak.netbird.YOUR_IP.nip.io",
"Audience": "netbird",
"TokenEndpoint": "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird/protocol/openid-connect/token",
"DeviceAuthEndpoint": "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird/protocol/openid-connect/auth/device",
"Scope": "openid",
"UseIDToken": false
}
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"ClientID": "netbird",
"ClientSecret": "",
"Audience": "netbird",
"TokenEndpoint": "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird/protocol/openid-connect/token",
"AuthorizationEndpoint": "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird/protocol/openid-connect/auth",
"Scope": "openid profile email",
"UseIDToken": false,
"RedirectURLs": ["http://localhost:53000"]
}
},
"Relay": {
"Addresses": ["rels://api.netbird.YOUR_IP.nip.io:443/relay"],
"CredentialsTTL": "24h",
"Secret": "GENERATE_ME"
},
"StoreConfig": {
"Engine": "sqlite"
}
}
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: YOUR_CLUSTER_ISSUER
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
hosts:
- host: api.netbird.YOUR_IP.nip.io
paths:
- path: /
pathType: Prefix
tls:
- secretName: netbird-management-tls
hosts:
- api.netbird.YOUR_IP.nip.io
signal:
enabled: true
port: 80
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: YOUR_CLUSTER_ISSUER
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
hosts:
- host: signal.netbird.YOUR_IP.nip.io
paths:
- path: /
pathType: Prefix
tls:
- secretName: netbird-signal-tls
hosts:
- signal.netbird.YOUR_IP.nip.io
dashboard:
enabled: true
env:
NETBIRD_MGMT_API_ENDPOINT: "https://api.netbird.YOUR_IP.nip.io"
NETBIRD_MGMT_GRPC_API_ENDPOINT: "https://api.netbird.YOUR_IP.nip.io"
AUTH_CLIENT_ID: "netbird"
AUTH_AUTHORITY: "https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird"
AUTH_AUDIENCE: "netbird"
USE_AUTH0: "false"
AUTH_SUPPORTED_SCOPES: "openid profile email"
NETBIRD_TOKEN_SOURCE: "accessToken"
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: YOUR_CLUSTER_ISSUER
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
hosts:
- host: app.netbird.YOUR_IP.nip.io
paths:
- path: /
pathType: Prefix
tls:
- secretName: netbird-dashboard-tls
hosts:
- app.netbird.YOUR_IP.nip.io
relay:
enabled: true
env:
NB_LOG_LEVEL: info
NB_LISTEN_ADDRESS: ":33080"
NB_EXPOSED_ADDRESS: "rels://api.netbird.YOUR_IP.nip.io:443/relay"
NB_AUTH_SECRET: "GENERATE_ME" # Must match Relay.Secret in configmap above
Generate secrets before deploying:
# DataStoreEncryptionKey
openssl rand -base64 32
# Relay secret (NB_AUTH_SECRET + Relay.Secret — must match)
openssl rand -base64 24
The gRPC problem with Traefik
Standard Kubernetes Ingress resources don’t support gRPC (HTTP/2 without TLS passthrough). NetBird’s management and signal servers use gRPC for peer communication, so you need Traefik IngressRoute resources alongside the standard Ingress. Add these to your values under extraManifests:
extraManifests:
- apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: netbird-management-grpc
namespace: netbird
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`api.netbird.YOUR_IP.nip.io`) && PathPrefix(`/management`)
services:
- name: netbird-management
port: 80
scheme: h2c
- kind: Rule
match: Host(`api.netbird.YOUR_IP.nip.io`) && PathPrefix(`/api`)
services:
- name: netbird-management
port: 80
- kind: Rule
match: Host(`api.netbird.YOUR_IP.nip.io`) && PathPrefix(`/relay`)
services:
- name: netbird-relay
port: 33080
tls:
secretName: netbird-management-tls
- apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: netbird-signal-grpc
namespace: netbird
spec:
entryPoints:
- websecure
routes:
- kind: Rule
match: Host(`signal.netbird.YOUR_IP.nip.io`) && PathPrefix(`/signalexchange`)
services:
- name: netbird-signal
port: 80
scheme: h2c
tls:
secretName: netbird-signal-tls
The scheme: h2c tells Traefik to speak HTTP/2 cleartext to the backend while terminating TLS at the ingress edge.
Deploy
helm install netbird netbird/netbird \
-n netbird --create-namespace \
-f netbird-values.yaml
Verify
# All pods should be Running
kubectl get pods -n netbird
# Ingresses should have addresses
kubectl get ingress -n netbird
# TLS certs should be Ready
kubectl get certificate -n netbird
If you’re using an internal CA (not Let’s Encrypt), the management pod needs to trust it to reach Keycloak. Mount the CA cert and set SSL_CERT_FILE:
management:
volumes:
- name: internal-ca
secret:
secretName: your-ca-secret
items:
- key: tls.crt
path: ca.crt
volumeMounts:
- name: internal-ca
mountPath: /etc/ssl/internal-ca
readOnly: true
env:
SSL_CERT_FILE: /etc/ssl/internal-ca/ca.crt
Phase 2 — Keycloak Identity Provider
NetBird requires an OIDC-compliant IdP. For self-hosting, use Keycloak with the codecentric/keycloakx chart — the official Quarkus-based one. Skip Bitnami; it lags on Keycloak releases.
helm repo add codecentric https://codecentric.github.io/helm-charts
helm repo update
helm install keycloak codecentric/keycloakx \
-n keycloak --create-namespace \
-f keycloak-values.yaml
The minimum Keycloak values for Traefik + TLS:
keycloak:
extraEnv: |
- name: KC_PROXY_HEADERS
value: xforwarded
- name: KC_HOSTNAME
value: keycloak.netbird.YOUR_IP.nip.io
- name: KC_HTTP_ENABLED
value: "true"
ingress:
enabled: true
ingressClassName: traefik
annotations:
cert-manager.io/cluster-issuer: YOUR_CLUSTER_ISSUER
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
rules:
- host: keycloak.netbird.YOUR_IP.nip.io
paths:
- path: /
pathType: Prefix
tls:
- secretName: keycloak-tls
hosts:
- keycloak.netbird.YOUR_IP.nip.io
Create the netbird realm
- Log into
https://keycloak.netbird.YOUR_IP.nip.io/admin - Click the realm dropdown (top-left, shows “master”) → Create realm
- Set Realm name to
netbird, enable it, click Create
Do all remaining steps inside the netbird realm.
Create the OIDC client
Navigate to Clients → Create client.
General Settings:
| Field | Value |
|---|---|
| Client type | OpenID Connect |
| Client ID | netbird |
Capability Config:
| Field | Value |
|---|---|
| Client authentication | OFF (public client — required for device auth) |
| Standard flow | ON |
| Device Authorization Grant | ON ← this is the critical one for CLI enrollment |
| Everything else | OFF |
Login Settings:
| Field | Value |
|---|---|
| Root URL | https://app.netbird.YOUR_IP.nip.io |
| Valid redirect URIs | https://app.netbird.YOUR_IP.nip.io/* and http://localhost:53000 |
| Web origins | https://app.netbird.YOUR_IP.nip.io |
http://localhost:53000is required for CLI peer enrollment. Thenetbird upcommand uses PKCE flow, opens a browser, and listens on that local port for the OAuth callback. Without it, Keycloak rejects the auth request with “Invalid parameter: redirect_uri”.
Add the Audience mapper
By default, Keycloak omits the client ID from the aud claim in access tokens issued to public clients. NetBird management validates aud on every request. Without it, all API calls return 401 even after a successful login.
Navigate to: Clients → netbird → Client scopes tab → click netbird-dedicated → Add mapper → By configuration → Audience
| Field | Value |
|---|---|
| Name | netbird-audience |
| Included Client Audience | netbird |
| Add to access token | ON |
| Add to ID token | OFF |
Every access token now contains "aud": ["netbird"] and management accepts it.
Ensure email and profile scopes are assigned
In Clients → netbird → Client scopes, confirm email and profile appear in the Assigned default client scopes list. NetBird needs sub, email, and name claims from those scopes.
Verify the OIDC discovery endpoint
curl -s https://keycloak.netbird.YOUR_IP.nip.io/realms/netbird/.well-known/openid-configuration \
| python3 -m json.tool | head -20
Confirm these fields are present: issuer, device_authorization_endpoint, token_endpoint.
Create test users
Navigate to Users → Create new user. Set a username, email (with Email verified: ON), first and last name. After saving, go to the Credentials tab, set a password with Temporary: OFF.
Phase 3 — Enroll Peers
First: claim the owner role
The first user to log into the NetBird dashboard becomes the owner. Do this immediately after deployment before anyone else logs in.
- Open
https://app.netbird.YOUR_IP.nip.io - Log in with your Keycloak admin user
- You now own the NetBird account
Enroll a Linux peer
# Install on Debian/Ubuntu
curl -fsSL https://pkgs.netbird.io/install.sh | sh
# Connect to your management server
netbird up --management-url https://api.netbird.YOUR_IP.nip.io
The CLI will print a URL and a short code. Open the URL in a browser, authenticate via Keycloak, and the peer connects automatically.
Enroll macOS
brew install netbird
netbird up --management-url https://api.netbird.YOUR_IP.nip.io
Windows: download the MSI from the NetBird releases page and run netbird up --management-url https://api.netbird.YOUR_IP.nip.io from PowerShell.
Verify peer connectivity
Once enrolled, each peer gets a WireGuard IP in the 100.x.x.x range. Peers in the same default group can reach each other:
netbird status
ping 100.x.x.x # the WireGuard IP of another peer
The Zero-Trust Model in Practice
With NetBird, access is peer-to-peer and governed by policy groups. There’s no network perimeter. A peer either has a route to a target because group policy allows it, or it doesn’t.
Every enrolled machine gets a WireGuard IP in the 100.64.0.0/10 range. Services only need to be reachable on that IP, not on the public internet. The setup:
- Enroll the server as a NetBird peer
- Firewall the server so services accept connections only from the NetBird subnet (
100.64.0.0/10), or bind them to the WireGuard interface - Keep public ports closed
In the dashboard, create Groups (e.g. developers, servers) and Policies that allow traffic between groups on specific ports. A peer outside the right group has no route, even if it’s connected to the mesh.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Dashboard redirect loops | proxy: edge / KC_PROXY_HEADERS not set in Keycloak | Set KC_PROXY_HEADERS: xforwarded |
invalid_client error | Wrong Client ID | Confirm AUTH_CLIENT_ID: netbird matches the Keycloak client ID |
| Device auth code never works | Device Authorization Grant not enabled | Enable it on the client Capability Config tab |
401 on all API calls after login | aud claim missing from token | Add Audience mapper to netbird-dedicated scope |
Invalid parameter: redirect_uri on CLI | http://localhost:53000 not in redirect URIs | Add it to Valid Redirect URIs in the client |
| Peers can’t reach each other | Not in the same policy group | Check Groups and Policies in the dashboard |
| Management pod can’t reach Keycloak | Internal CA not trusted | Mount CA cert + set SSL_CERT_FILE on management pod |
Browser can’t reach services after netbird up | NetBird intercepts DNS for its own domain | Add service hostnames to /etc/hosts — takes precedence over split DNS |
What’s Next
A few things to sort out before running this in production:
- SQLite → PostgreSQL: SQLite works for single-node testing but is not suitable for production. The management configmap’s
StoreConfig.Engineswitches topostgreswith connection details. - Secrets management: the values file above has secrets inline. For production, store them as Kubernetes
Secretobjects and reference them viasecretKeyRef. - Coturn for heavy NAT: the embedded relay via Traefik works well for most topologies. If you have peers behind aggressive NAT (CGNAT, symmetric NAT), a dedicated Coturn instance with a LoadBalancer service and an open UDP port range (3478 + 49152-65535) gives better relay coverage.
- LDAP federation: Keycloak supports federating against an existing LDAP/AD directory so users don’t need separate Keycloak accounts.
The full configuration lives in the k8s-netbird repo.
Need this set up for your team?
Incident-Readiness Review →