← PROJECT
#netbird#kubernetes#wireguard#keycloak#zero-trust#helm#self-hosted

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:

ComponentRoleExposed via
ManagementREST + gRPC API; peer registry, policy engineTraefik ingress (HTTPS + gRPC)
SignalWireGuard ICE/STUN signaling between peersTraefik ingress (gRPC)
DashboardWeb UI for admin and user self-serviceTraefik ingress (HTTPS)
RelayTURN-style relay for peers that can’t connect directlyTraefik 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/TCP inbound to your ingress IP (for management, signal, dashboard, relay)
    • 3478/UDP and 49152-65535/UDP inbound 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

  1. Log into https://keycloak.netbird.YOUR_IP.nip.io/admin
  2. Click the realm dropdown (top-left, shows “master”) → Create realm
  3. Set Realm name to netbird, enable it, click Create

Do all remaining steps inside the netbird realm.

Create the OIDC client

Navigate to ClientsCreate client.

General Settings:

FieldValue
Client typeOpenID Connect
Client IDnetbird

Capability Config:

FieldValue
Client authenticationOFF (public client — required for device auth)
Standard flowON
Device Authorization GrantON ← this is the critical one for CLI enrollment
Everything elseOFF

Login Settings:

FieldValue
Root URLhttps://app.netbird.YOUR_IP.nip.io
Valid redirect URIshttps://app.netbird.YOUR_IP.nip.io/* and http://localhost:53000
Web originshttps://app.netbird.YOUR_IP.nip.io

http://localhost:53000 is required for CLI peer enrollment. The netbird up command 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: ClientsnetbirdClient scopes tab → click netbird-dedicatedAdd mapperBy configurationAudience

FieldValue
Namenetbird-audience
Included Client Audiencenetbird
Add to access tokenON
Add to ID tokenOFF

Every access token now contains "aud": ["netbird"] and management accepts it.

Ensure email and profile scopes are assigned

In ClientsnetbirdClient 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 UsersCreate 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.

  1. Open https://app.netbird.YOUR_IP.nip.io
  2. Log in with your Keycloak admin user
  3. 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:

  1. Enroll the server as a NetBird peer
  2. Firewall the server so services accept connections only from the NetBird subnet (100.64.0.0/10), or bind them to the WireGuard interface
  3. 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

SymptomCauseFix
Dashboard redirect loopsproxy: edge / KC_PROXY_HEADERS not set in KeycloakSet KC_PROXY_HEADERS: xforwarded
invalid_client errorWrong Client IDConfirm AUTH_CLIENT_ID: netbird matches the Keycloak client ID
Device auth code never worksDevice Authorization Grant not enabledEnable it on the client Capability Config tab
401 on all API calls after loginaud claim missing from tokenAdd Audience mapper to netbird-dedicated scope
Invalid parameter: redirect_uri on CLIhttp://localhost:53000 not in redirect URIsAdd it to Valid Redirect URIs in the client
Peers can’t reach each otherNot in the same policy groupCheck Groups and Policies in the dashboard
Management pod can’t reach KeycloakInternal CA not trustedMount CA cert + set SSL_CERT_FILE on management pod
Browser can’t reach services after netbird upNetBird intercepts DNS for its own domainAdd 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.Engine switches to postgres with connection details.
  • Secrets management: the values file above has secrets inline. For production, store them as Kubernetes Secret objects and reference them via secretKeyRef.
  • 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 →