\
+ -e NB_PROXY_ADDRESS=:443 \
-p 80:80 -p 443:443 \
netbirdio/reverse-proxy:latest
```
@@ -340,3 +393,4 @@ For deeper debugging, raise the log level to `debug` or `trace` (`NB_PROXY_LOG_L
- [Authentication](/manage/reverse-proxy/authentication) — SSO, password, PIN, header, and access restrictions.
- [Access Logs](/manage/reverse-proxy/access-logs) — auditing for traffic going through your proxies.
- [Running Multiple Proxy Instances](/selfhosted/maintenance/scaling/multiple-proxy-instances) — operational patterns for HA proxy clusters.
+- [Reverse Proxy on OpenShift](/manage/reverse-proxy/openshift) — deploy the proxy on OpenShift under the `restricted-v2` SCC with a TCP LoadBalancer or TLS-passthrough Routes.
diff --git a/src/pages/manage/reverse-proxy/index.mdx b/src/pages/manage/reverse-proxy/index.mdx
index d8ab9e10..d0977a8a 100644
--- a/src/pages/manage/reverse-proxy/index.mdx
+++ b/src/pages/manage/reverse-proxy/index.mdx
@@ -53,16 +53,27 @@ The service mode determines how the proxy handles traffic between clients and yo
| Mode | Layer | Description |
|------|-------|-------------|
| **HTTP** | L7 | TLS termination at the proxy, HTTP-level forwarding. Supports path-based routing, host header forwarding, redirect rewriting, and browser-based authentication (SSO, password, PIN). |
-| **TCP** | L4 | Raw TCP relay. The proxy accepts TCP connections on a dedicated port and forwards them to your backend. |
-| **UDP** | L4 | UDP relay with session tracking. The proxy accepts UDP packets on a dedicated port and forwards them to your backend. Sessions are reaped after an idle timeout. |
+| **TCP** | L4 | Raw TCP relay. The proxy accepts TCP connections on the service's listen port and forwards them to your backend. |
+| **UDP** | L4 | UDP relay with session tracking. The proxy accepts UDP packets on the service's listen port and forwards them to your backend. Sessions are reaped after an idle timeout. |
| **TLS** | L4 | TLS passthrough with SNI-based routing. The proxy inspects the TLS ClientHello to read the SNI hostname and forwards the encrypted connection to your backend without terminating TLS. |
-L4 services (TCP, UDP, TLS) listen on a dedicated port on the proxy cluster. Depending on the cluster, the port may be auto-assigned or you can specify one manually. The proxy cluster's `supports_custom_ports` capability determines whether manual port selection is available.
+L4 services (TCP, UDP, TLS) listen on a port on the proxy cluster, and several services can share a port. TCP and UDP services can have their port auto-assigned or, on clusters that support custom ports (`supports_custom_ports`), chosen by you. TLS services always need a port you choose. See [Port allocation for L4 services](#port-allocation-for-l4-services) and [How services share ports](#how-services-share-ports).
L4 services do not support browser-based authentication (SSO, password, PIN) or header authentication because there is no HTTP layer. You can use [access restrictions](/manage/reverse-proxy/authentication#access-restrictions) (IP CIDR, country, and CrowdSec rules) to protect L4 services.
+### Protocol support
+
+HTTP services carry WebSocket connections and Server-Sent Events streams, and serve clients over HTTP/2 or HTTP/1.1. HTTP/3 is not advertised.
+
+Toward the backend, the proxy uses HTTP/2 only over TLS: a target with protocol `http` is always reached over HTTP/1.1, even if the backend supports HTTP/2 without TLS (h2c). This matters for gRPC, which runs over HTTP/2:
+
+- **gRPC backend with TLS**: use an HTTP service with an `https` target.
+- **gRPC backend without TLS**: use a TCP service, which relays the connection unchanged.
+
+TCP services relay other TCP protocols the same way, such as SSH and RDP.
+
### Targets
A target defines where proxied traffic is sent within your NetBird network. Every target specifies a type and port. HTTP services additionally support path-based routing.
@@ -81,6 +92,7 @@ Target properties vary by service mode:
- **Path** (optional) - a URL path prefix for path-based routing (e.g., `/api`). See [Path-based routing](#path-based-routing) below.
- **Protocol** - `HTTP` or `HTTPS`, depending on what the backend service speaks
- **Port** - the port on the target machine (defaults to `80` for HTTP, `443` for HTTPS)
+- **Access** (optional) - use the service authentication, bypass authentication, or block access for the target's matching path. Configure this under **Optional Settings**. See [Per-target access actions](/manage/reverse-proxy/authentication#per-target-access-actions).
- **Enabled/Disabled toggle** - individually enable or disable targets without removing them
**L4 services (TCP, UDP, TLS):**
@@ -252,6 +264,7 @@ In the **Details** tab:
6. In the target configuration, select the **type** (Peer, Host, Domain, Subnet, or Proxy Cluster), then choose the specific peer, resource, or cluster.
7. For HTTP services, set the **protocol** (HTTP or HTTPS) and **port** for the target. Optionally, enter a **path** for path-based routing. For L4 services, set the target **host/IP** and **port**. For **Proxy Cluster** targets, the host field accepts any hostname or IP the cluster's embedded proxy can resolve from its own host stack — see [Private services](/manage/reverse-proxy/bring-your-own-proxy#private-services-net-bird-only-access) for details.
+8. For an HTTP target, expand **Optional Settings** and choose an **Access** action if this path should differ from the service default. **Use service authentication** is the default. See [Per-target access actions](/manage/reverse-proxy/authentication#per-target-access-actions).
@@ -349,15 +362,17 @@ Within a service, you can:
## Port allocation for L4 services
-L4 services (TCP, UDP, TLS) require a dedicated port on the proxy cluster. How ports are assigned depends on whether the proxy cluster supports custom port selection:
+L4 services (TCP, UDP, TLS) listen on a port on the proxy cluster. How ports are assigned depends on whether the proxy cluster supports custom port selection:
- **Custom port selection available**: you choose the exact port the proxy listens on. This is useful when clients expect a well-known port (e.g., 5432 for PostgreSQL, 3306 for MySQL). Self-hosted proxy clusters support this when configured to allow it.
-- **Auto-assigned ports only**: the proxy cluster automatically assigns an available port. This is the case for NetBird's shared cloud proxy clusters, where port allocation is managed to avoid conflicts between accounts. The assigned port is shown in the service details after creation.
+- **Auto-assigned ports only**: the proxy cluster automatically assigns an available port to TCP and UDP services. This is the case for NetBird's shared cloud proxy clusters, where port allocation is managed to avoid conflicts between accounts. The assigned port is shown in the service details after creation.
-The dashboard indicates whether a proxy cluster supports custom ports when you select the domain. If custom ports are not supported, the listen port field is read-only and populated after creation.
+TLS services are the exception: they always need a listen port that you choose, including on clusters without custom port selection, because TLS services share ports by SNI hostname. Creating a TLS service without a listen port fails.
+
+The dashboard indicates whether a proxy cluster supports custom ports when you select the domain. If custom ports are not supported, the listen port field of a TCP or UDP service is read-only and populated after creation. For a TLS service, the field stays editable.
- Each port on a proxy cluster can only be used by one service at a time. If you specify a port that is already in use by another service, creation will fail. L4 listen ports also cannot conflict with the proxy's tunnel port.
+ A port on a proxy cluster can be shared by any number of TLS services, told apart by their SNI hostname, plus at most one TCP service and at most one UDP service. The TCP service receives every connection that matches no TLS service (or, on the main port, no HTTP service). Creating a TCP or UDP service fails if the port already has one of that protocol, and creating any service fails if another service already uses its domain. L4 listen ports also cannot conflict with the proxy's tunnel port.
@@ -365,7 +380,7 @@ The dashboard indicates whether a proxy cluster supports custom ports when you s
- **Self-hosted Docker deployments:** The default Docker Compose configuration only routes port 443 (via Traefik TLS passthrough) to the proxy container. L4 services that listen on additional TCP or UDP ports require you to manually expose those ports in your `docker-compose.yml`. See the [migration guide](/selfhosted/migration/enable-reverse-proxy#exposing-l4-ports) for instructions.
+ **Self-hosted Docker deployments:** The default Docker Compose configuration only routes port 443 (via Traefik TLS passthrough) to the proxy container, where the proxy's main port is `8443` (`NB_PROXY_ADDRESS`). A TLS service given listen port `443` in such a deployment therefore listens on a custom port inside the container, not on the main port. L4 services that listen on additional TCP or UDP ports require you to manually expose those ports in your `docker-compose.yml`. See the [migration guide](/selfhosted/migration/enable-reverse-proxy#exposing-l4-ports) for instructions. A Bring Your Own Proxy deployment has the same requirement; see [Expose TCP and UDP services](/manage/reverse-proxy/bring-your-own-proxy#expose-tcp-and-udp-services). In both cases, running the proxy on the host network removes the per-port Docker step.
### How services share ports
@@ -376,32 +391,38 @@ The proxy's main port always runs an SNI router that peeks at the TLS ClientHell
- Each incoming connection is matched against configured domains.
- If the SNI hostname matches an **HTTP** service, the connection is forwarded to the HTTP reverse proxy for L7 handling.
- If the SNI hostname matches a **TLS** service, the encrypted connection is passed through directly to the backend without TLS termination.
-- If the SNI hostname does not match any service, the connection falls through to the HTTP reverse proxy, which returns an error since no matching service exists.
-- Connections with no SNI (e.g., TLS 1.3 with Encrypted Client Hello) follow the same fallback path.
+- If the SNI hostname does not match any service, the connection goes to the TCP service on that port if there is one, and otherwise falls through to the HTTP reverse proxy, which returns an error since no matching service exists.
+- Connections with no SNI, and connections that are not TLS at all, follow the same fallback path.
- Do not configure an HTTP and a TLS service with the same hostname. The HTTP route takes priority and the TLS service becomes unreachable. Use different domain names for each.
+ A domain can belong to only one service, so an HTTP and a TLS service cannot share a hostname: creating the second one fails with `domain already taken`. Use a different domain for each.
-**On custom ports**, TLS and TCP services can coexist:
+**On custom ports**, any number of TLS services and at most one TCP service can share the same port:
- TLS connections are matched by SNI and passed through to the backend.
- Connections that do not match any SNI route (or are not TLS at all) fall back to the TCP relay.
- This is useful for running a TLS passthrough alongside a plain TCP catch-all on the same port.
+- If the port has no TCP service, connections that match no TLS service are dropped.
+- HTTP services are served only on the main port. On a custom port, a request for an HTTP service's hostname matches no route, so it goes to the TCP service if there is one and is dropped otherwise.
## Path-based routing
-When a service has multiple targets, you can assign each target a unique path prefix to route different URL paths to different backends. For example:
+When a service has multiple targets, you can assign each target a unique path prefix to route different URL paths to different backends. Each HTTP target can also choose what happens after its path matches:
-| Path | Target | Description |
-|------|--------|-------------|
-| `/` | Peer A (port 3000) | Main web application |
-| `/api` | Peer B (port 8080) | API service |
-| `/docs` | Resource C (port 80) | Documentation server |
+| Path | Target | Access | Example result |
+|------|--------|--------|----------------|
+| `/` | Peer A (port 3000) | Use service authentication | `/account` uses the authentication configured on the service |
+| `/public/` | Peer B (port 8080) | Bypass authentication | `/public/logo.svg` reaches Peer B without service authentication |
+| `/admin/` | Resource C (port 80) | Block access | `/admin/users` returns `403`; Resource C is not contacted |
-Incoming requests are matched against the configured path prefixes and forwarded to the corresponding target. Each path must be unique within a service.
+Incoming requests are matched against the configured path prefixes. When an enabled target uses **Bypass authentication** or **Block access**, all enabled target paths must be unique within the service. If more than one prefix matches, the **longest matching prefix** wins. In the example above, `/public/logo.svg` matches both `/` and `/public/`, so the `/public/` target and its access action are selected.
This is useful for consolidating multiple internal services under a single public domain, reducing the number of domains and TLS certificates you need to manage.
+Prefix matching is literal and case-sensitive. Glob patterns such as `*` or `**` and regular expressions are not supported. Prefixes match the request path as text, not by path segment, so a target on `/api` also receives `/api-docs`. To match a whole segment, end the path with a slash: a target on `/api/` receives `/api/x`, but not `/api-docs` or `/api` itself. Likewise, `/Public/x` does not match `/public/`.
+
+The access action does not change how paths are forwarded. By default, the matched prefix is removed before the request reaches the backend: `/api/x` arrives as `/x`, and `/api-docs` as `/-docs`. To keep the full path, use the existing **Preserve Full Path** target setting (`path_rewrite: preserve` in the API).
+
## Integration with Networks
If you have already configured [Networks](/manage/networks) with resources and routing peers, you can expose a network resource directly from the Networks page.
@@ -430,6 +451,7 @@ For the complete guide including setup, authentication options, session lifecycl
- [Expose from CLI](/manage/reverse-proxy/expose-from-cli) - expose local services from the command line using `netbird expose`
- [Multiple Proxy Instances](/selfhosted/maintenance/scaling/multiple-proxy-instances) - deploy multiple proxy instances for high availability and redundancy
+- [Reverse Proxy on OpenShift](/manage/reverse-proxy/openshift) - run the rootless UBI proxy image on OpenShift with automatic certificates
- [Custom Domains](/manage/reverse-proxy/custom-domains) - configure your own domain names for reverse proxy services
- [Authentication](/manage/reverse-proxy/authentication) - SSO, password, PIN, header authentication, and access restrictions
- [Access Logs](/manage/reverse-proxy/access-logs) - monitor and audit traffic to your reverse proxy services
diff --git a/src/pages/manage/reverse-proxy/openshift.mdx b/src/pages/manage/reverse-proxy/openshift.mdx
new file mode 100644
index 00000000..8af6e314
--- /dev/null
+++ b/src/pages/manage/reverse-proxy/openshift.mdx
@@ -0,0 +1,580 @@
+import {Note, Warning} from "@/components/mdx"
+
+export const description =
+ 'Run the NetBird reverse proxy on Red Hat OpenShift with the rootless UBI image, automatic Let\'s Encrypt certificates, and either a TCP LoadBalancer or TLS-passthrough Routes for public exposure.'
+
+# Reverse Proxy on OpenShift
+
+This guide deploys the NetBird reverse proxy on OpenShift so an external client can reach a backend Service over the NetBird overlay. The proxy obtains and renews its own TLS certificates with ACME, runs under the stock `restricted-v2` security context constraint, and needs no custom SCC, privileged mode, host networking, or elevated capabilities.
+
+It uses the rootless proxy image built on Red Hat Universal Base Image (UBI) 9 and published for Linux AMD64 and ARM64. The image runs as a non-root user and keeps its writable directories group-writable so OpenShift can assign an arbitrary UID. The manifest in [Step 2](#step-2-deploy-the-proxy) pins the image tag; pin a version or digest for reproducible deployments and see [NetBird releases](https://github.com/netbirdio/netbird/releases) for new versions.
+
+By the end of this guide, the namespace contains:
+
+| Resource | Name | Purpose |
+|----------|------|---------|
+| Secret | `netbird-proxy-config` | Proxy token, cluster domain, and management address |
+| PersistentVolumeClaim | `netbird-proxy-certs` | ACME account key and issued certificates |
+| Deployment | `netbird-proxy` | The NetBird reverse proxy, one replica |
+| Service | `netbird-proxy` | `ClusterIP`, or `LoadBalancer` for the preferred exposure |
+| Route | One per service hostname | Only for the TLS-passthrough fallback |
+| Deployment and Service | `nginx-server` | Optional test backend |
+
+## How it works
+
+The proxy is an ordinary NetBird reverse proxy instance: it registers with your management server over gRPC, receives the service mappings for its cluster domain, requests a certificate from Let's Encrypt for each service hostname, terminates TLS, and forwards traffic through the NetBird overlay to a routing peer that reaches the backend Service.
+
+```mermaid
+flowchart LR
+ internet[Internet] -->|TCP 443| entry[Public TCP entrypoint]
+ entry -->|TLS intact| proxy[NetBird reverse proxy]
+ proxy ==>|NetBird overlay| peer[Routing peer]
+ peer -->|HTTP| app[Backend Service]
+ proxy -.->|gRPC: token, service mappings| mgmt[NetBird management]
+ proxy -.->|ACME| le[Let's Encrypt]
+```
+
+Only the proxy is reachable from the Internet. The backend Service stays `ClusterIP` and is reached through the routing peer only.
+
+The public TCP entrypoint must forward TCP 443 to the proxy **without terminating TLS**, so the proxy can answer the Let's Encrypt `tls-alpn-01` challenge and present the issued certificate itself. OpenShift offers two ways to do that. Decide which one applies to your cluster before you start, because it determines your DNS records and your ongoing work:
+
+| | [TCP LoadBalancer](#preferred-exposure-a-tcp-load-balancer) (preferred) | [TLS-passthrough Routes](#fallback-without-a-load-balancer-tls-passthrough-routes) (fallback) |
+|---|---|---|
+| Requires | A LoadBalancer provider that allocates a publicly reachable address | The public OpenShift ingress router |
+| DNS | Wildcard `CNAME` for service hostnames | Wildcard `CNAME` for service hostnames |
+| Work per new service | None | Create one Route for the service hostname |
+| Custom domains | DNS records only | DNS records and one Route per hostname |
+
+## Prerequisites
+
+Before you start, make sure you have:
+
+- An OpenShift login with permission to create Deployments, Services, Secrets, PersistentVolumeClaims and, for the Route fallback, Routes in the target namespace. No additional SCC is needed.
+- A default StorageClass that can provision a 1 GiB PVC.
+- A **proxy access token** from your NetBird management server. This is not a client setup key or a personal access token:
+ - **NetBird Cloud** - create an account-scoped token in the dashboard under **Reverse Proxy** > **Clusters** > **Setup Self-Hosted Cluster**, or through the API. See [Bring Your Own Proxy](/manage/reverse-proxy/bring-your-own-proxy). The management address is `https://api.netbird.io`.
+ - **Self-hosted** - create a management-wide token with `netbird-server admin token create` (combined container) or `netbird-mgmt admin token create` (multi-container). See [Enable Reverse Proxy](/selfhosted/migration/enable-reverse-proxy). The management address is your management server's public URL. Because this proxy runs outside the management host's Docker network, the management server's Traefik must also route the `ProxyService` gRPC path and have its idle timeout disabled. Follow [Prepare the management server for cross-host proxies](/selfhosted/maintenance/scaling/multiple-proxy-instances#prepare-the-management-server-for-cross-host-proxies) first. This [Traefik requirement](/manage/reverse-proxy#traefik-requirement) applies to the management server, not to the proxy deployed here.
+- Public DNS names you control, and inbound TCP 443 connectivity from the Internet to the proxy.
+- Outbound access from the cluster to your NetBird management server, the configured signal and relay services, Let's Encrypt, and `pkgs.netbird.io`, from which the proxy downloads its geolocation database at startup.
+- A NetBird routing peer that can reach the backend Service. [Step 4](#step-4-connect-a-backend) uses the rootless UBI client from the [OpenShift installation guide](/get-started/install/openshift) deployed in the same cluster.
+
+
+ If the namespace has a default-deny NetworkPolicy, allow egress from the proxy pod to DNS, the management server, signal, relays, Let's Encrypt, and `pkgs.netbird.io` on TCP 443. Also allow ingress to the proxy pod on TCP 8443: from the OpenShift router for the Route fallback (namespaces labeled `policy-group.network.openshift.io/ingress: ""`), or from the load balancer's source addresses for the preferred exposure. Allow the kubelet to reach TCP 8080 for the health probes if your policy restricts host-network traffic.
+
+
+The guide uses three values. Replace the examples with your own:
+
+| Variable | Example | Purpose |
+|----------|---------|---------|
+| `NB_PROXY_DOMAIN` | `proxy.example.com` | The **cluster domain**. The proxy registers under this domain, and services are created as subdomains of it. It is also the value of the proxy's `NB_PROXY_DOMAIN` environment variable. |
+| `NB_PROXY_MANAGEMENT_ADDRESS` | `https://netbird.example.com:443` | The management server that issued your token, `https://api.netbird.io` for NetBird Cloud. It is also the value of the proxy's `NB_PROXY_MANAGEMENT_ADDRESS` environment variable. |
+| `SERVICE_HOSTNAME` | `nginx.proxy.example.com` | The **service hostname** users visit. Shell variable only; the proxy learns service hostnames from management. Here it is a subdomain of the cluster domain; a verified [custom domain](/manage/reverse-proxy/custom-domains) works the same way. |
+
+Export them for the commands in this guide, then create the namespace:
+
+```bash
+export NB_PROXY_DOMAIN=proxy.example.com
+export NB_PROXY_MANAGEMENT_ADDRESS=https://netbird.example.com:443
+export SERVICE_HOSTNAME=nginx.proxy.example.com
+export NAMESPACE=netbird-proxy
+
+oc create namespace "$NAMESPACE" --dry-run=client -o yaml | oc apply -f -
+```
+
+## Step 1: Create the configuration Secret
+
+The Deployment reads its token, cluster domain, and management address from a Secret named `netbird-proxy-config`. Create it with your proxy access token:
+
+```bash
+export NB_PROXY_TOKEN=nbx_replace_with_your_proxy_token
+
+oc create secret generic netbird-proxy-config -n "$NAMESPACE" \
+ --from-literal=proxy-token="$NB_PROXY_TOKEN" \
+ --from-literal=proxy-domain="$NB_PROXY_DOMAIN" \
+ --from-literal=management-address="$NB_PROXY_MANAGEMENT_ADDRESS" \
+ --dry-run=client -o yaml | oc apply -f -
+```
+
+The command is safe to re-run to change a value. The proxy picks up changes only after a restart: `oc rollout restart deployment/netbird-proxy -n "$NAMESPACE"`.
+
+## Step 2: Deploy the proxy
+
+Self-issued ACME certificates and their account keys live on a single writable volume. The Deployment therefore runs one replica with the `Recreate` strategy, uses filesystem locking for the certificate cache, and mounts a PersistentVolumeClaim at `/certs` so the ACME account and issued certificates survive pod replacement.
+
+Save the following as `netbird-proxy.yaml`. It needs no edits; every environment-specific value comes from the Secret, and the namespace comes from the `oc apply` command.
+
+```yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: netbird-proxy-certs
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 1Gi
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: netbird-proxy
+ labels:
+ app.kubernetes.io/name: netbird-proxy
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: netbird-proxy
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: netbird-proxy
+ spec:
+ automountServiceAccountToken: false
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: proxy
+ image: ghcr.io/netbirdio/reverse-proxy:0.80.0-ubi
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: NB_PROXY_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: netbird-proxy-config
+ key: proxy-token
+ - name: NB_PROXY_DOMAIN
+ valueFrom:
+ secretKeyRef:
+ name: netbird-proxy-config
+ key: proxy-domain
+ - name: NB_PROXY_MANAGEMENT_ADDRESS
+ valueFrom:
+ secretKeyRef:
+ name: netbird-proxy-config
+ key: management-address
+ - name: NB_PROXY_ADDRESS
+ value: :8443
+ - name: NB_PROXY_HEALTH_ADDRESS
+ value: :8080
+ - name: NB_PROXY_ACME_CERTIFICATES
+ value: "true"
+ - name: NB_PROXY_ACME_CHALLENGE_TYPE
+ value: tls-alpn-01
+ - name: NB_PROXY_CERTIFICATE_DIRECTORY
+ value: /certs
+ - name: NB_PROXY_CERT_LOCK_METHOD
+ value: flock
+ - name: NB_PROXY_SUPPORTS_CUSTOM_PORTS
+ value: "false"
+ ports:
+ - name: https
+ containerPort: 8443
+ - name: health
+ containerPort: 8080
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop:
+ - ALL
+ startupProbe:
+ httpGet:
+ path: /healthz/startup
+ port: health
+ periodSeconds: 5
+ timeoutSeconds: 3
+ failureThreshold: 60
+ readinessProbe:
+ httpGet:
+ path: /healthz/ready
+ port: health
+ periodSeconds: 10
+ timeoutSeconds: 3
+ livenessProbe:
+ httpGet:
+ path: /healthz/live
+ port: health
+ periodSeconds: 10
+ timeoutSeconds: 3
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ volumeMounts:
+ - name: certificates
+ mountPath: /certs
+ volumes:
+ - name: certificates
+ persistentVolumeClaim:
+ claimName: netbird-proxy-certs
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: netbird-proxy
+ labels:
+ app.kubernetes.io/name: netbird-proxy
+spec:
+ type: ClusterIP
+ selector:
+ app.kubernetes.io/name: netbird-proxy
+ ports:
+ - name: https
+ port: 443
+ targetPort: https
+```
+
+Apply it and wait for the rollout:
+
+```bash
+oc apply -n "$NAMESPACE" -f netbird-proxy.yaml
+oc rollout status deployment/netbird-proxy -n "$NAMESPACE" --timeout=5m
+```
+
+The Service is `ClusterIP` on purpose. [Step 3](#step-3-expose-the-proxy) either switches it to `LoadBalancer` or leaves it internal and puts Routes in front of it.
+
+
+ On OpenShift, leave `runAsUser`, `runAsGroup`, and `fsGroup` unset. The `restricted-v2` admission applies the namespace's allowed identity and volume group for you, and the image is built to work under that arbitrary UID.
+
+
+**Checkpoint.** The startup probe passes only after the proxy has connected to management and completed its initial mapping sync, so a finished rollout confirms the token and management address are correct. Check the PVC and the logs:
+
+```bash
+oc get pvc netbird-proxy-certs -n "$NAMESPACE"
+oc logs deployment/netbird-proxy -n "$NAMESPACE" --tail=50
+```
+
+The PVC is `Bound`, the logs contain `Initial mapping sync complete`, and the cluster appears in the dashboard under **Reverse Proxy** > **Clusters** with an **Online** badge. If the rollout times out, see [Troubleshooting](#troubleshooting).
+
+### Configuration explained
+
+| Setting | Why |
+|---------|-----|
+| `replicas: 1` and `strategy: Recreate` | One process owns the certificate cache on the PVC. A rolling update would briefly run two writers against the same volume. See [Running more than one replica](#running-more-than-one-replica). |
+| `NB_PROXY_CERT_LOCK_METHOD=flock` | Filesystem locking on the PVC. The `auto` default may select the Kubernetes lease backend, which needs a service account with lease RBAC. This Deployment mounts no service account token. |
+| `NB_PROXY_ADDRESS=:8443` | The proxy listens on an unprivileged port. The Service maps port 443 to it. |
+| `NB_PROXY_HEALTH_ADDRESS=:8080` | Binds the health endpoint to all interfaces so kubelet can reach the startup, readiness, and liveness probes. The same endpoint serves [Prometheus metrics](/selfhosted/observability/proxy). |
+| `NB_PROXY_ACME_CERTIFICATES=true` and `NB_PROXY_ACME_CHALLENGE_TYPE=tls-alpn-01` | The proxy issues and renews certificates itself over port 443. Port 80 is not required. `tls-alpn-01` issues certificates for individual hostnames, not wildcards. See [TLS-ALPN-01 requirements](/manage/reverse-proxy#tls-alpn-01-requirements) and, if you need `http-01`, [Using http-01 instead of tls-alpn-01](#using-http-01-instead-of-tls-alpn-01). |
+| `NB_PROXY_CERTIFICATE_DIRECTORY=/certs` on a PVC | Retains the ACME account key and certificates across pod replacement. Keep the PVC; deleting it forces fresh issuance and counts against Let's Encrypt rate limits. |
+| `NB_PROXY_SUPPORTS_CUSTOM_PORTS=false` | Neither the Service nor the Routes expose arbitrary TCP or UDP ports, so the cluster does not advertise the **Custom Ports** capability. |
+| `automountServiceAccountToken: false` and `capabilities: drop: [ALL]` | The proxy needs no Kubernetes API access and no Linux capabilities. |
+
+For the full list of variables, see the [environment variable reference](/selfhosted/migration/enable-reverse-proxy#environment-variable-reference).
+
+## Step 3: Expose the proxy
+
+Pick the option you chose in [How it works](#how-it-works). In both cases, public TCP 443 must reach the proxy's port 8443 for the initial certificate issuance as well as every renewal.
+
+### Preferred exposure: a TCP LoadBalancer
+
+If your cluster has a LoadBalancer provider, change the Service type. No Route is needed in this mode:
+
+```bash
+oc patch service netbird-proxy -n "$NAMESPACE" -p '{"spec":{"type":"LoadBalancer"}}'
+oc get service netbird-proxy -n "$NAMESPACE"
+```
+
+A working provider allocates an externally reachable address in the `EXTERNAL-IP` column and forwards TCP 443 to the proxy as plain TCP. A cloud network load balancer or MetalLB with public routing both qualify. MetalLB alone does not make a private address reachable from the Internet.
+
+Point the cluster domain at the provisioned address with an `A`/`AAAA` record for an IP, or a `CNAME` for a load balancer hostname. Then point the service hostnames at the cluster domain with a wildcard `CNAME`:
+
+| Record type | Name | Value |
+|-------------|------|-------|
+| `A` or `CNAME` | `proxy.example.com` | The load balancer's IP or hostname |
+| `CNAME` | `*.proxy.example.com` | `proxy.example.com` |
+
+Publish an `AAAA` record only if the IPv6 path also works end to end. The NetBird proxy routes by the hostname in the TLS handshake and handles every configured service hostname itself, so new services need no further OpenShift changes.
+
+**Checkpoint.** `EXTERNAL-IP` shows an address instead of ``, and `dig "$SERVICE_HOSTNAME"` resolves to it.
+
+### Fallback without a LoadBalancer: TLS-passthrough Routes
+
+If the cluster has no LoadBalancer provider, use the existing public OpenShift ingress router instead. Leave the `netbird-proxy` Service as `ClusterIP`. If you already switched it to `LoadBalancer` and `EXTERNAL-IP` stayed ``, switch it back:
+
+```bash
+oc patch service netbird-proxy -n "$NAMESPACE" -p '{"spec":{"type":"ClusterIP"}}'
+```
+
+The OpenShift router picks a backend by matching the Route's hostname against the SNI in the TLS handshake, not by Service name. **Every service hostname NetBird knows about therefore needs its own Route with TLS passthrough, pointing at the proxy Service.** It is one Route per hostname, not one Kubernetes Service per hostname.
+
+```mermaid
+flowchart LR
+ internet[Internet] -->|TCP 443| router[OpenShift router]
+ router -->|SNI match, TLS passthrough| svc[proxy Service]
+ svc -->|TCP 8443| proxy[NetBird reverse proxy]
+ proxy ==>|NetBird overlay| peer[Routing peer]
+ peer -->|HTTP| app[Backend Service]
+```
+
+Create the Route for the example service hostname:
+
+```bash
+oc create route passthrough nginx-proxy -n "$NAMESPACE" \
+ --hostname="$SERVICE_HOSTNAME" \
+ --service=netbird-proxy \
+ --port=https
+```
+
+The equivalent manifest, applied with `oc apply -n "$NAMESPACE" -f`:
+
+```yaml
+apiVersion: route.openshift.io/v1
+kind: Route
+metadata:
+ name: nginx-proxy
+spec:
+ host: nginx.proxy.example.com
+ to:
+ kind: Service
+ name: netbird-proxy
+ port:
+ targetPort: https
+ tls:
+ termination: passthrough
+```
+
+Then configure DNS. The cluster domain points at the public OpenShift ingress endpoint, and the service hostnames point at the cluster domain:
+
+| Record type | Name | Value |
+|-------------|------|-------|
+| `CNAME` | `proxy.example.com` | The router's public endpoint, for example `router-default.apps..example.com` |
+| `CNAME` | `*.proxy.example.com` | `proxy.example.com` |
+
+DNS gets the connection to the router. The Route's hostname match gets it from the router to the NetBird proxy. A `CNAME` does not change the hostname sent in the TLS handshake, so the Route must match the service hostname exactly. A Route for the bare cluster domain or for a parent domain does not match `nginx.proxy.example.com`.
+
+
+ Use `passthrough` termination only. With `edge` or `reencrypt`, the router terminates TLS, clients receive the cluster's wildcard certificate, and the `tls-alpn-01` challenge never completes.
+
+
+**Checkpoint.** The router admitted the Route:
+
+```bash
+oc get route nginx-proxy -n "$NAMESPACE" \
+ -o jsonpath='{range .status.ingress[*].conditions[*]}{.type}={.status}: {.message}{"\n"}{end}'
+```
+
+The output shows `Admitted=True`, and `dig "$SERVICE_HOSTNAME"` resolves to the router's public endpoint.
+
+#### Adding more services
+
+Each service you add in NetBird later, including services on a custom domain, needs its own Route before its certificate can be issued. Create it with a unique name, keeping `--service=netbird-proxy --port=https`:
+
+```bash
+oc create route passthrough app-proxy -n "$NAMESPACE" \
+ --hostname=app.proxy.example.com \
+ --service=netbird-proxy \
+ --port=https
+```
+
+If a Route for the hostname already exists, update it rather than creating a duplicate.
+
+## Step 4: Connect a backend
+
+The backend proves end-to-end connectivity. It should be reachable only through the proxy and the NetBird overlay, never directly from the Internet.
+
+### Deploy a test backend
+
+Deploy an unprivileged nginx listening on port 8080, fronted by a `ClusterIP` Service named `nginx-server` that maps HTTP port 80 to the container's port 8080. Save it as `nginx-server.yaml` and apply it with `oc apply -n "$NAMESPACE" -f nginx-server.yaml`. Keep this Service internal; the public entrypoint targets the NetBird proxy, not nginx.
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: nginx-server
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: nginx-server
+ template:
+ metadata:
+ labels:
+ app: nginx-server
+ spec:
+ automountServiceAccountToken: false
+ securityContext:
+ runAsNonRoot: true
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: nginx
+ image: docker.io/nginxinc/nginx-unprivileged:stable-alpine
+ ports:
+ - name: http
+ containerPort: 8080
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop: [ALL]
+ readinessProbe:
+ httpGet:
+ path: /
+ port: http
+ resources:
+ requests:
+ cpu: 10m
+ memory: 16Mi
+ limits:
+ memory: 64Mi
+ volumeMounts:
+ - name: tmp
+ mountPath: /tmp
+ volumes:
+ - name: tmp
+ emptyDir: {}
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: nginx-server
+spec:
+ type: ClusterIP
+ selector:
+ app: nginx-server
+ ports:
+ - name: http
+ port: 80
+ targetPort: http
+```
+
+### Add the backend to NetBird
+
+The proxy reaches the backend through a routing peer, so the backend has to exist as a network resource first:
+
+1. **Deploy a routing peer** in the cluster. Follow [OpenShift Installation](/get-started/install/openshift) to run the rootless UBI client, preferably with a [persistent peer identity](/get-started/install/openshift#persistent-peer-identity) so the routing peer assignment survives restarts.
+2. **Create a network and a domain resource** with the address `nginx-server.netbird-proxy.svc.cluster.local` (replace `netbird-proxy` if you changed `NAMESPACE`), and assign the UBI client as its routing peer. Any pod in the cluster can resolve and reach this Service. See [Networks](/manage/networks).
+3. **Add an access policy** that lets the proxy's embedded peer reach the resource. The proxy joins your NetBird network as a peer when it receives its first service mapping. See [Access Control](/manage/access-control/manage-network-access).
+4. **Create the reverse proxy service** under **Reverse Proxy** > **Services** > **Add Service**. See the [Reverse Proxy quick start](/manage/reverse-proxy#quick-start) for the full dialog.
+
+ | Field | Value |
+ |-------|-------|
+ | Subdomain | `nginx` |
+ | Base domain | Your cluster domain, `proxy.example.com` |
+ | Mode | **HTTP** |
+ | Target | The `nginx-server` **Domain** resource |
+ | Protocol | **HTTP** |
+ | Port | `80` |
+ | Authentication | As needed. See [Authentication](/manage/reverse-proxy/authentication). |
+
+The proxy requests a certificate for a service hostname only when management sends it the service mapping, so this last step is what triggers issuance. A DNS record or a Route alone does not register a service.
+
+**Checkpoint.** The service under **Reverse Proxy** > **Services** moves from `certificate_pending` to `active`, and the proxy logs contain `certificate for domain "nginx.proxy.example.com" ready`.
+
+
+ Until the first certificate is ready, the proxy logs `TLS handshake error ... acme/autocert: missing certificate` for connections that arrive through the router or load balancer, and may log a single `orderNotReady` error while issuance completes. These warnings are expected and stop once the certificate is issued. If they continue for more than a few minutes, see [Troubleshooting](#troubleshooting).
+
+
+## Step 5: Verify end to end
+
+From outside the cluster, check HTTPS without disabling certificate verification:
+
+```bash
+curl --head --show-error --connect-timeout 10 "https://$SERVICE_HOSTNAME/"
+```
+
+HTTPS must present a trusted certificate for the service hostname. An authentication redirect or denial is expected if you enabled authentication on the service; after authenticating in a browser, the nginx welcome page confirms the backend is reachable over the overlay.
+
+Optionally, confirm the pod was admitted under `restricted-v2` with an OpenShift-assigned UID:
+
+```bash
+oc get pods -n "$NAMESPACE" -l app.kubernetes.io/name=netbird-proxy \
+ -o 'custom-columns=NAME:.metadata.name,SCC:.metadata.annotations.openshift\.io/scc,UID:.spec.containers[0].securityContext.runAsUser'
+```
+
+## Operating the proxy
+
+### Upgrading
+
+Change the image tag in `netbird-proxy.yaml` and re-apply it. Because the Deployment uses `Recreate`, the old pod stops before the new one starts, so expect a short interruption for every service on this cluster. The PVC keeps the issued certificates, so the new pod does not request new ones.
+
+### Running more than one replica
+
+This configuration runs a single replica because the certificate cache is on a `ReadWriteOnce` volume. Do not raise `replicas` as-is. For high availability, run additional instances with the same cluster domain, each with its own certificate volume, or switch to a shared wildcard certificate as described in [Using cert-manager instead of ACME](#using-cert-manager-instead-of-acme). See [Running Multiple Proxy Instances](/selfhosted/maintenance/scaling/multiple-proxy-instances#tls-certificate-management) for the trade-offs.
+
+### Using cert-manager instead of ACME
+
+If cert-manager already issues certificates in your cluster, you can mount its TLS Secret at `/certs` as a read-only volume, set `NB_PROXY_ACME_CERTIFICATES=false`, and drop the PVC and lock settings. The certificate must cover the cluster domain and `*.{cluster domain}` for the proxy to serve every service hostname. Choose one certificate-management mode for the Deployment rather than combining both. See [TLS certificate configuration](/manage/reverse-proxy#tls-certificate-configuration).
+
+### Using http-01 instead of tls-alpn-01
+
+This guide uses `tls-alpn-01` because it needs only TCP 443. Switch to `http-01` only if your ACME CA or network policy requires it.
+
+With `http-01`, Let's Encrypt validates each service hostname by connecting to it on **public port 80**. The proxy answers on a separate plain-HTTP listener set by `NB_PROXY_ACME_ADDRESS`. The UBI image sets it to `:8081`, because a non-root container under `restricted-v2` cannot bind ports below 1024. **Public port 80 must reach container port 8081.** If only TCP 443 is exposed, as in the rest of this guide, every validation fails and services stay in `certificate_pending`.
+
+1. In the Deployment, change the challenge type, set the challenge address explicitly, and expose the port:
+
+ ```yaml
+ env:
+ - name: NB_PROXY_ACME_CHALLENGE_TYPE
+ value: http-01
+ - name: NB_PROXY_ACME_ADDRESS
+ value: :8081
+ ports:
+ - name: acme-http
+ containerPort: 8081
+ ```
+
+2. Add port 80 to the `netbird-proxy` Service, next to the existing `https` port:
+
+ ```yaml
+ ports:
+ - name: https
+ port: 443
+ targetPort: https
+ - name: http
+ port: 80
+ targetPort: acme-http
+ ```
+
+3. Re-apply the manifest and confirm the load balancer forwards both TCP 80 and TCP 443 from the Internet.
+
+The challenge listener answers only ACME requests and redirects all other HTTP requests to HTTPS, so opening port 80 does not expose services over plain HTTP.
+
+
+ `http-01` requires the [TCP LoadBalancer](#preferred-exposure-a-tcp-load-balancer) exposure. It does not work with the TLS-passthrough Route fallback: a passthrough Route only carries TLS traffic on port 443, and the router does not forward plain HTTP on port 80 to the proxy for that hostname. Use `tls-alpn-01` with Routes.
+
+
+### Removing the deployment
+
+Delete the NetBird services that use this cluster in the dashboard first, then remove the OpenShift resources:
+
+```bash
+oc delete namespace "$NAMESPACE"
+```
+
+This also deletes the certificate PVC. If you plan to redeploy with the same hostnames soon, delete the Deployment, Service, and Routes instead and keep the PVC to avoid re-issuing certificates.
+
+## Troubleshooting
+
+The rows follow the order in which things happen: pod start, management connection, public exposure, certificate issuance, and backend traffic.
+
+| Symptom | What to check |
+|---------|---------------|
+| Pod fails with a permissions error under `/certs` or `/var/lib/netbird` | Confirm the pod runs under `restricted-v2` with no fixed `runAsUser` or `fsGroup`, that the PVC is `Bound` and mounted read-write at `/certs`, and that the StorageClass supports pod volume permissions. Do not add privileged init containers or relax the SCC to work around it. |
+| Proxy logs `Unauthenticated` right after start | Wrong or revoked token, or a token from a different management server than the `management-address` in the Secret. Update the Secret and restart the Deployment. |
+| Rollout times out; management gRPC logs HTML `404`/`502` responses | The management server's reverse proxy is not routing the `ProxyService` gRPC path. See [Prepare the management server for cross-host proxies](/selfhosted/maintenance/scaling/multiple-proxy-instances#prepare-the-management-server-for-cross-host-proxies). |
+| Logs repeat `management connection failed ... PROTOCOL_ERROR` followed by `Initial mapping sync complete`, about once a minute | The rollout succeeds, but the management server's reverse proxy or load balancer cuts long-lived HTTP/2 streams after an idle timeout. Disable or raise that timeout for the `ProxyService` gRPC path. See [Prepare the management server for cross-host proxies](/selfhosted/maintenance/scaling/multiple-proxy-instances#prepare-the-management-server-for-cross-host-proxies). |
+| Rollout times out with no management errors | Check egress from the namespace to the management server, signal, and relays, including any NetworkPolicy or egress firewall. |
+| `EXTERNAL-IP` stays `` on the LoadBalancer Service | The cluster has no working LoadBalancer provider. Use the [Route fallback](#fallback-without-a-load-balancer-tls-passthrough-routes) instead, and switch the Service back to `ClusterIP` first. |
+| The cluster's wildcard certificate is returned for the service hostname | Traffic reaches the router but not the proxy. Check the exact Route `host`, its admission status, the target Service and port, and that termination is `passthrough`. |
+| Service stays in `certificate_pending`, or ACME reports `no viable challenge type found` | Validation failed; the error does not only mean an unsupported challenge type. Check the public `A`/`AAAA`/`CNAME` records, end-to-end TCP 443 to the proxy, and, for the Route fallback, that a Route exists for this exact hostname. Also confirm the hostname is under the cluster domain or a verified [custom domain](/manage/reverse-proxy/custom-domains) attached to this cluster, and that any [CAA records](/manage/reverse-proxy/custom-domains#check-your-caa-records) allow `letsencrypt.org`. |
+| With `http-01`, services stay in `certificate_pending` | Let's Encrypt could not reach public port 80. Check that the Service maps port 80 to container port 8081, that the load balancer forwards TCP 80, and that you are not using the Route fallback. If the logs show `ACME HTTP-01 challenge server failed` with a permission error, `NB_PROXY_ACME_ADDRESS` is a port below 1024; set it to `:8081`. See [Using http-01 instead of tls-alpn-01](#using-http-01-instead-of-tls-alpn-01). |
+| Cannot write certificates or acquire a lock | Check that the PVC is bound and mounted read-write at `/certs`, and that `NB_PROXY_CERT_LOCK_METHOD` is `flock`. |
+| HTTPS works but nginx is unreachable, or the service stays in `tunnel_not_created` | Check the routing peer is connected, the access policy covers the proxy's embedded peer, the resource address resolves from the routing peer, and the target uses HTTP on port 80. See [Reverse Proxy Troubleshooting](/manage/reverse-proxy/troubleshooting). |
+
+Do not repeatedly delete the certificate PVC or restart the proxy to retry issuance before fixing DNS or public routing. Each attempt counts against Let's Encrypt rate limits.
+
+## Related pages
+
+- [OpenShift Installation](/get-started/install/openshift) - run the rootless UBI NetBird client on OpenShift, including as a routing peer
+- [Bring Your Own Proxy](/manage/reverse-proxy/bring-your-own-proxy) - account-scoped proxy tokens and clusters on NetBird Cloud
+- [Enable Reverse Proxy](/selfhosted/migration/enable-reverse-proxy) - management-wide proxy tokens and the full environment variable reference
+- [Running Multiple Proxy Instances](/selfhosted/maintenance/scaling/multiple-proxy-instances) - management server preparation for remote proxies and HA patterns
+- [Custom Domains](/manage/reverse-proxy/custom-domains) - use your own domain for service hostnames
+- [Reverse Proxy Troubleshooting](/manage/reverse-proxy/troubleshooting) - diagnose backend connectivity problems
diff --git a/src/pages/manage/reverse-proxy/troubleshooting.mdx b/src/pages/manage/reverse-proxy/troubleshooting.mdx
index 188e1653..dc409ce0 100644
--- a/src/pages/manage/reverse-proxy/troubleshooting.mdx
+++ b/src/pages/manage/reverse-proxy/troubleshooting.mdx
@@ -308,6 +308,20 @@ Once you regain access to the dashboard, you can re-enable geo-restrictions on t
- Leave geo-restrictions disabled on services that the management server needs to reach internally (such as your IdP).
- Use a separate network path for the IdP that doesn't pass through the reverse proxy's geo-restriction layer.
+### Issue 4: TCP service is active but connections get no response
+
+**Symptoms**:
+- A TCP service shows as active and its listen port accepts connections
+- Clients connect but receive no response
+
+**Cause**:
+
+A service's status reflects the proxy's listener, not the backend: a TCP service stays active and keeps accepting connections even when nothing listens on its target port.
+
+**Solution**:
+
+Check the backend, not the service status: on the target host, confirm that the application is listening on the target port and binds to an interface the proxy can reach (step 6 of the [Quick Diagnostics Checklist](#quick-diagnostics-checklist), and Issue 2).
+
## Advanced Debugging with Packet Capture
If the checks above don't reveal the issue, you can use packet capture tools to verify whether traffic is actually arriving on the routing peer or target machine. This is especially useful for diagnosing routing, firewall, or NAT issues.
diff --git a/src/pages/manage/settings/clients.mdx b/src/pages/manage/settings/clients.mdx
index ef70039d..938c2b76 100644
--- a/src/pages/manage/settings/clients.mdx
+++ b/src/pages/manage/settings/clients.mdx
@@ -1,5 +1,5 @@
export const description =
- 'Configure account-wide client behavior: automatic updates, lazy connections, and exposing services from the CLI.'
+ 'Configure account-wide client behavior: automatic updates, lazy connections, exposing services from the CLI, and unique peer hostnames.'
# Client Settings
@@ -29,6 +29,24 @@ Instead of maintaining always-on connections, NetBird can activate peer connecti
See [Lazy Connections](/manage/peers/lazy-connection) for how activation works, inactivity thresholds, and per-client overrides.
+## Unique Peer Hostnames
+
+Two machines in one account can register with the same hostname, for example similar VMs that a provisioning tool all named `laptop`. Every peer still needs its own DNS name, so by default NetBird keeps the registration and adds the last two octets of the peer's NetBird IP to the DNS label: a second `laptop` that gets the IP `100.64.12.7` becomes `laptop-12-7.netbird.cloud`. Renaming a peer to a name another peer already uses gets the same suffix.
+
+The suffix keeps every peer reachable, but it also hides the duplicate. If you want a registration with a duplicate hostname to fail loudly instead, enable the `Reject Hostname Collisions` toggle. With the toggle on:
+
+- A peer registering with a hostname that another peer in the account already uses is rejected. The client reports `peer with hostname "laptop" is already registered`, stops retrying, and stays disconnected until it logs in again with a different hostname.
+- Renaming a peer in the dashboard or through the API to a name another peer already uses fails with the same error.
+- Ephemeral peers are treated exactly like other peers.
+
+Collisions are matched on the DNS label, not the raw hostname. The label is the hostname lowercased with characters that are not valid in DNS replaced by `-`, so `Laptop` and `laptop` collide, and so do `my_laptop` and `my-laptop`.
+
+Enabling the toggle does not touch existing peers: labels that already carry a suffix keep it, and a peer logging in again under its own name is not a collision. Deleting a peer frees its name for the next registration. To let a rejected peer join, give the machine a unique hostname or start the client with one: `netbird up --hostname `.
+
+Keep the toggle off if your fleet registers machines with identical hostnames on purpose, such as autoscaled VMs or containers that join through a [setup key](/manage/peers/register-machines-using-setup-keys). The default suffix exists for that case, and with the toggle on every such machine after the first fails to join.
+
+This setting is available on NetBird Cloud and on self-hosted management servers running v0.81.0 or later. In the API it is the `peer_hostname_collision_rejected` field of the [account settings](/ipa/resources/accounts).
+
## Agent Network-focused view
Accounts using [Agent Network](/agent-network) see an additional `Agent Network focused view` toggle. When enabled, the dashboard shows only the Agent Network-related sections and hides parts that are not relevant for it, such as Networks, DNS, and Reverse Proxy. Disable it to bring back the full dashboard.
diff --git a/src/pages/manage/settings/delete-account.mdx b/src/pages/manage/settings/delete-account.mdx
index a5001940..e63e0143 100644
--- a/src/pages/manage/settings/delete-account.mdx
+++ b/src/pages/manage/settings/delete-account.mdx
@@ -19,7 +19,7 @@ To delete your NetBird organization account, you must be a user with the [owner
After you delete your account, your session will end, and you will be redirected to the login page.
- If you log in again, a new account will be created.
+ If you log in again, a new account will be created. The exception on NetBird Cloud is an email domain that already belongs to another account, either as its signup domain or as a verified [sign-in domain](/manage/team/sign-in-domains). In that case you join that account instead, and may need an admin to approve you.
## Get started
diff --git a/src/pages/manage/settings/plans-and-billing.mdx b/src/pages/manage/settings/plans-and-billing.mdx
index b4976517..eadfb7f5 100644
--- a/src/pages/manage/settings/plans-and-billing.mdx
+++ b/src/pages/manage/settings/plans-and-billing.mdx
@@ -11,6 +11,10 @@ It features peer-to-peer connections and encryption, access control, routing, an
- **Business Plan:** At **€12 per user per month**, the Business Plan offers enhanced network security with a Zero Trust approach. It supports unlimited users and includes features like device approvals and integrations (MDM and EDR) for comprehensive control, device posture checks and activity events streaming, making it an excellent choice for organizations seeking advanced security solutions.
+
+ These plans apply to NetBird Cloud. If you self-host NetBird, the paid features (such as IdP provisioning, device approvals, MDM and EDR integrations, traffic events logging, and event streaming) come from an [Enterprise Commercial License](/selfhosted/enterprise) instead. See the [on-prem pricing page](https://netbird.io/pricing#on-prem).
+
+
diff --git a/src/pages/manage/team/add-users-to-your-network.mdx b/src/pages/manage/team/add-users-to-your-network.mdx
index b4615b2f..b8ca19d1 100644
--- a/src/pages/manage/team/add-users-to-your-network.mdx
+++ b/src/pages/manage/team/add-users-to-your-network.mdx
@@ -24,6 +24,8 @@ The domain can fall into one of the following categories - `public`, `private`,
The domains of the private category are the ones that are automatically grouped under the same account.
Public domains are the ones of the public email providers like Gmail.
+Users on any other domain your organization uses, such as `company.co.uk` or a subsidiary's domain, don't join automatically. Add and verify those domains as [sign-in domains](/manage/team/sign-in-domains) so their users join your account too.
+
It might happen (unlikely) that the domain classification system didn't classify your company's domain as private.
Our system was unsure about your domain and assigned an unclassified or public category to be on the safe side.
@@ -43,8 +45,9 @@ After logging in to the system, they will join your network automatically.
- If a user already has a NetBird account, you can't invite them.
- This is a limitation that is likely to be removed in future versions.
+ On NetBird Cloud, you can't invite someone who already has a NetBird account, for example one they created by signing in before you invited them. The invite fails with a `409` error. See [User already belongs to another NetBird account](/help/troubleshooting-user-in-another-account) to move them into your account.
+
+ On self-hosted NetBird with the embedded identity provider, a `409` means the email address already belongs to a user in your account. Look for them under **Team → Users** instead.
## Identity Provider (IdP) Sync
diff --git a/src/pages/manage/team/approve-users.mdx b/src/pages/manage/team/approve-users.mdx
index e96ed584..ad9f3952 100644
--- a/src/pages/manage/team/approve-users.mdx
+++ b/src/pages/manage/team/approve-users.mdx
@@ -4,6 +4,10 @@ import {Note} from "@/components/mdx";
The user approval feature enhances security by requiring manual administrator approval before a user can join your NetBird organization via domain matching.
New NetBird Cloud accounts have user approval enabled by default.
+
+Signed in and waiting for approval yourself? See [Pending approval and account invitations](/help/troubleshooting-account-access) for what the Waiting for Approval screen means and what to do next.
+
+
## Require user approval
Navigate to the Dashboard's `Settings` page and the `Authentication` tab and enable or disable `User Approval Required`.
@@ -17,6 +21,8 @@ Navigate to the Dashboard's `Settings` page and the `Authentication` tab and ena
## Approve or reject user
To approve a user, navigate to the [Users Page](https://app.netbird.io/team/users) and click the `Approve` or `Reject` button on the right side of the users table.
+While they wait, the user sees a **Waiting for Approval** screen that names the account owner's email address, partly hidden, when NetBird can look it up. Otherwise it just asks them to contact the account owner. Any admin can approve them, not only the owner. Once approved, the user clicks **Refresh** on that screen to join the account.
+
diff --git a/src/pages/manage/team/idp-sync/index.mdx b/src/pages/manage/team/idp-sync/index.mdx
index 9d3867b3..af7765f5 100644
--- a/src/pages/manage/team/idp-sync/index.mdx
+++ b/src/pages/manage/team/idp-sync/index.mdx
@@ -20,7 +20,11 @@ or update network configurations like [DNS](/manage/dns#distribution-groups),
eliminating the need for manual grouping.
- This feature is only available in the cloud version of NetBird in the [Team plan](https://netbird.io/pricing) and above.
+ This feature is available in NetBird Cloud on the [Team plan](https://netbird.io/pricing) and above, and in self-hosted deployments with an [Enterprise Commercial License](/selfhosted/enterprise). The open source Community Edition does not include it.
+
+
+
+ **Before you roll out on NetBird Cloud**: IdP sync can't add a user who already belongs to another NetBird account, for example because they signed in early with an email domain your account doesn't know yet. Add every domain your users sign in with as a [sign-in domain](/manage/team/sign-in-domains) first. If a user still doesn't sync, see [User already belongs to another NetBird account](/help/troubleshooting-user-in-another-account).
This video guide walks you through an example integration with Microsoft Entra ID, covering both user onboarding and
diff --git a/src/pages/manage/team/idp-sync/okta-sync.mdx b/src/pages/manage/team/idp-sync/okta-sync.mdx
index 26379f10..b9c243b6 100644
--- a/src/pages/manage/team/idp-sync/okta-sync.mdx
+++ b/src/pages/manage/team/idp-sync/okta-sync.mdx
@@ -18,9 +18,7 @@ to synchronize users and groups smoothly.
## Get Started with NetBird-Okta Integration
-To set up SSO, go to `Integrations` in the NetBird admin console's left menu to access the Identity Provider integration page. Click the `Connect Okta` button to get started with the Okta-NetBird integration. This will open a pop-up window with detailed instructions on synchronizing NetBird and Okta.
-
-
+To set up SSO, open `Integrations` in the NetBird admin console's left menu and select the `Single Sign-On` tab. On the Okta card, click `Connect Okta`. This opens the `Connect NetBird with Okta SSO` wizard, which walks you through the rest of the SSO setup. Its first screen lists the Okta permissions you need, covered in the prerequisites below. You set up SCIM later, from the `Identity Provider Sync` tab.
## Prerequisites
@@ -41,7 +39,7 @@ Confirm that you have one of the required roles before proceeding with the integ
## Installing the NetBird Integration
-Once you have the necessary permissions, you can set up the NetBird application. First, on NetBird, click `Continue →` to show a summary of the necessary steps.
+Once you have the necessary permissions, you can set up the NetBird application. In the NetBird wizard, click `Get Started →`. The wizard shows the `Install NetBird application for Okta` steps.
Let's go through them one by one:
@@ -57,15 +55,15 @@ You will see a list of users. Find your user account, click `Assign`, and save t
## Configuring SSO in Okta
-The next step is to configure Okta-NetBird SSO integration.
+The next step is to collect Okta's OpenID Connect details and enter them in NetBird. SSO setup is self-service: you don't need to send these details to the NetBird team.
-In NetBird, click the `Continue →` button. A new wizard screen will appear, offering the instructions for retrieving Okta’s OpenID Connect credentials. You can click `Close` and navigate to Okta.
+In NetBird, click the `Continue →` button. The wizard shows the `Enter your Okta details` form. Keep it open and switch to Okta to collect the values.
* Click on the `Sign On` tab on Okta. Look for `OpenID Connect` under `Sign on methods` in the `Settings` section.
* Copy the `Client ID` value.
* Copy the `Client Secret` value.
-Store these credentials securely, as you will need them soon.
+Treat the `Client Secret` like a password. Paste it straight into the NetBird wizard, and don't share it over email or chat.

@@ -78,16 +76,26 @@ Store these credentials securely, as you will need them soon.
* On the top right, click on your username
* Copy your [Okta account domain](https://developer.okta.com/docs/guides/find-your-domain/main/), shown under your email address in that menu, for example `trial-1234567.okta.com`.
-The final step is to [send an email to the NetBird team](support@netbird.io) with the authentication information you just retrieved:
+Back in the NetBird wizard, fill in the form with the values you collected:
-* Okta `Client ID`
-* Okta `Client secret`
-* Okta account domain
-* Okta primary email domain (usually your username)
+* `Client ID` and `Client Secret`: the values from the Okta `Sign On` tab.
+* `Okta account domain`: for example `trial-1234567.okta.com`.
+* `Primary E-Mail Domain`: the domain of the email addresses your users sign in with, for example `mycompany.com`.
-You will receive an email once the NetBird team enables authentication for your account.
+

-This completes the first stage, enabling Single Sign-On (SSO) from NetBird's login page using Okta credentials. Now, you can navigate to [app.netbird.io](app.netbird.io) and log in using [Okta Verify](https://help.okta.com/eu/en-us/content/topics/end-user/ov-overview.htm).
+Click `Connect`. If your primary email domain isn't verified yet, NetBird asks you to prove you own it. The `Verify Domain Ownership` dialog shows a TXT record to add to your DNS:
+
+* `Host`: your primary email domain, for example `mycompany.com`.
+* `Value`: `nb-verification=` followed by a token unique to your account. Copy it from the dialog.
+
+Sign in to your DNS provider, add the TXT record, then click `Start Verification`.
+
+DNS changes can take a while to apply. If NetBird doesn't find the record straight away, click `Verify Later` and try again once the record has propagated: on the `Single Sign-On` tab, click `Settings` on the Okta card, open the `Domains` tab, and click `Verify` next to the domain. If you can't edit DNS for the domain, you can verify it by emailing [support@netbird.io](mailto:support@netbird.io) from an address on that domain instead.
+
+When the domain shows `Ownership Verified`, the Okta card on the `Single Sign-On` tab shows `Active`.
+
+This completes the first stage, enabling Single Sign-On (SSO) from NetBird's login page using Okta credentials. Now, you can navigate to [app.netbird.io](https://app.netbird.io) and log in with your Okta account.
## Enabling Okta SCIM in NetBird
diff --git a/src/pages/manage/team/sign-in-domains.mdx b/src/pages/manage/team/sign-in-domains.mdx
new file mode 100644
index 00000000..faa24739
--- /dev/null
+++ b/src/pages/manage/team/sign-in-domains.mdx
@@ -0,0 +1,85 @@
+import {Note, Warning} from "@/components/mdx"
+
+export const description =
+ 'Users on your business email domain join your account automatically. Add and verify the other domains your business uses, so their users join the same account instead of creating one of their own.'
+
+# Allow Users from Other Domains to Join Your Account
+
+By default, users who sign in with the same business email domain as yours join the same account.
+For example, everyone on `@company.com` lands in your network, with the same peers and policies.
+
+In most cases a business uses more than one domain: a country domain like `company.co.uk`, a subsidiary like `company-berlin.de`, or a second brand.
+A colleague signing in as `jane@company.co.uk` is not recognized as yours unless you [invite them manually](/manage/team/add-users-to-your-network), so they
+get an account of their own.
+
+**Sign-in domains** allow users from other domains to join your account without manually inviting them beforehand.
+You prove ownership of those domains, and everyone across your organization joins the same account automatically.
+
+
+ **Availability**: Sign-in Domains is only available on NetBird Cloud.
+
+
+ **Permissions**: Only an Owner or Admin can manage sign-in domains. Learn more about [user roles](/manage/team/user-roles).
+
+
+
+ Verifying a domain does not move users who already have an account of their own. It decides where **new** users land, so add your domains before onboarding a team from another domain rather than after.
+
+ If someone already has an account of their own, inviting or syncing them fails. See [User already belongs to another NetBird account](/help/troubleshooting-user-in-another-account) to move them into yours. If they don't know where that account came from, have them reach out to [NetBird Support](/help/netbird-support). The team verifies their identity and points them to whoever administers that account.
+
+
+## Add Domain
+
+Go to **Settings → Authentication**, find **Sign-in Domains**, then click **Add** and enter the domain, e.g. `company.co.uk`.
+
+
+
+
+
+The domain starts as **Pending** until you prove you own it.
+
+## Verify Domain
+
+Click **Verify** on the pending domain. NetBird shows a TXT record to publish at the root of the domain, so on `company.co.uk` itself rather than `www.company.co.uk`.
+
+
+
+
+
+Add the record, then click **Start Verification**. NetBird keeps checking on its own, so you can leave the page. The domain becomes **Verified** once the record is found. If it never appears the domain ends up **Unverified**, and **Verify** starts the checks again.
+
+If you cannot change DNS, contact [NetBird Support](/help/netbird-support) from an email address on the domain instead, and the team will verify it for you.
+
+
+ A domain can only be verified by one account. If another account already holds it, your checks keep failing — contact [NetBird Support](/help/netbird-support).
+
+
+Once the domain is Verified, a user signing in for the first time with an address on it joins your account.
+
+
+
+
+
+## Remove Domain
+
+Click the delete icon on the domain's row. The domain you signed up with cannot be removed, and neither can a domain an SSO integration still uses.
+
+Removing a domain does not remove or lock out users who already joined through it. It only stops matching new sign-ins, who get accounts of their own again.
+
+## Recap
+
+- A sign-in domain tells NetBird that an email domain belongs to your account, so new users join your network instead of creating one of their own.
+- Your signup domain is there from the start and cannot be removed; add and verify any others yourself.
+- Verification is a TXT record at the root of the domain, re-checked automatically until it is found.
+- It changes where new users land, not where existing ones already are.
+
+## Related Docs
+
+- [Authenticate to NetBird with Single Sign-On](/manage/team/single-sign-on)
+- [Add users to your network](/manage/team/add-users-to-your-network)
+- [User roles](/manage/team/user-roles)
+- [Approve users](/manage/team/approve-users)
+- [Provision users and groups from your IdP](/manage/team/idp-sync)
diff --git a/src/pages/manage/team/single-sign-on/authentik.mdx b/src/pages/manage/team/single-sign-on/authentik.mdx
index 0abcc99d..84fbf01e 100644
--- a/src/pages/manage/team/single-sign-on/authentik.mdx
+++ b/src/pages/manage/team/single-sign-on/authentik.mdx
@@ -7,6 +7,7 @@ You can use Authentik as your Identity Provider with NetBird, but it will requir
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
1. You need to create a new Application and Provider.
diff --git a/src/pages/manage/team/single-sign-on/aws-cognito.mdx b/src/pages/manage/team/single-sign-on/aws-cognito.mdx
index d12149e3..ae7a3908 100644
--- a/src/pages/manage/team/single-sign-on/aws-cognito.mdx
+++ b/src/pages/manage/team/single-sign-on/aws-cognito.mdx
@@ -7,6 +7,7 @@ You can use [AWS Cognito](https://aws.amazon.com/cognito/) as your Identity Prov
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
## Prerequisites
diff --git a/src/pages/manage/team/single-sign-on/cidaas.mdx b/src/pages/manage/team/single-sign-on/cidaas.mdx
index dd9dd64c..b2428605 100644
--- a/src/pages/manage/team/single-sign-on/cidaas.mdx
+++ b/src/pages/manage/team/single-sign-on/cidaas.mdx
@@ -7,6 +7,7 @@ You can use [cidaas](https://www.cidaas.com/) as your Identity Provider with Net
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
## Step 1: Create a New Application
diff --git a/src/pages/manage/team/single-sign-on/duo-security.mdx b/src/pages/manage/team/single-sign-on/duo-security.mdx
index 62479f74..1428ed0f 100644
--- a/src/pages/manage/team/single-sign-on/duo-security.mdx
+++ b/src/pages/manage/team/single-sign-on/duo-security.mdx
@@ -7,6 +7,7 @@ You can use Duo Security as your Identity Provider with NetBird, but it will req
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
## Prerequisites
diff --git a/src/pages/manage/team/single-sign-on/iij-id.mdx b/src/pages/manage/team/single-sign-on/iij-id.mdx
index d36a879e..b98931a4 100644
--- a/src/pages/manage/team/single-sign-on/iij-id.mdx
+++ b/src/pages/manage/team/single-sign-on/iij-id.mdx
@@ -9,6 +9,7 @@ NetBird integrates with IIJ ID as a custom OpenID Connect application.
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
## Step 1: Create a Custom OpenID Connect Application
diff --git a/src/pages/manage/team/single-sign-on/index.mdx b/src/pages/manage/team/single-sign-on/index.mdx
index 1af0ae40..4f30e0db 100644
--- a/src/pages/manage/team/single-sign-on/index.mdx
+++ b/src/pages/manage/team/single-sign-on/index.mdx
@@ -44,6 +44,7 @@ to integrate with NetBird. Below are the steps to set up different OIDC-complian
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
### Authentik
diff --git a/src/pages/manage/team/single-sign-on/zitadel.mdx b/src/pages/manage/team/single-sign-on/zitadel.mdx
index 3d8cca3b..262f4106 100644
--- a/src/pages/manage/team/single-sign-on/zitadel.mdx
+++ b/src/pages/manage/team/single-sign-on/zitadel.mdx
@@ -7,6 +7,7 @@ You can use Zitadel as your Identity Provider with NetBird, but it will require
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
## Step 1: Create a New Project (Optional)
diff --git a/src/pages/manage/team/single-sign-on/zoho.mdx b/src/pages/manage/team/single-sign-on/zoho.mdx
index fca619b2..8d023821 100644
--- a/src/pages/manage/team/single-sign-on/zoho.mdx
+++ b/src/pages/manage/team/single-sign-on/zoho.mdx
@@ -9,6 +9,7 @@ NetBird integrates with Zoho as a custom OIDC application.
Support for OIDC-compliant IdPs is available on the Team plan and higher.
The Free plan supports Google, Microsoft, and social logins.
+ Self-hosted deployments support OIDC-compliant IdPs in both the open source Community Edition and the [Enterprise Commercial License](/selfhosted/enterprise). See [self-hosted identity providers](/selfhosted/identity-providers).
## Step 1: Create a Custom OIDC Application
diff --git a/src/pages/manage/team/user-roles.mdx b/src/pages/manage/team/user-roles.mdx
index a9a31f1d..fc6aa014 100644
--- a/src/pages/manage/team/user-roles.mdx
+++ b/src/pages/manage/team/user-roles.mdx
@@ -1,10 +1,10 @@
-import {Note} from "@/components/mdx"
+import {Note, Warning} from "@/components/mdx"
-export const description = "Understand NetBird's user roles — Owner, Admin, Network Admin, Billing Admin, Auditor, and User — what each can access, and how to assign them."
+export const description = "Understand NetBird's user roles: Owner, Admin, Network Admin, Billing Admin, Auditor, User, Agent Network Admin, and Usage Viewer. Learn what each can access and how to assign them."
# User Roles
-NetBird has six user roles - `Owner`, `Admin`, `Network Admin`, `Billing Admin`, `Auditor`, and `User`. A user's role controls the level of access they have to your account, both in the dashboard and through the management API.
+NetBird has eight user roles - `Owner`, `Admin`, `Network Admin`, `Billing Admin`, `Auditor`, `User`, and two roles scoped to [Agent Network](/agent-network), `Agent Network Admin` and `Usage Viewer`. A user's role controls the level of access they have to your account, both in the dashboard and through the management API.
## Permissions at a glance
@@ -31,6 +31,23 @@ Rows are grouped by what a `Network Admin` can do, from full access down to no a
2 A `Billing Admin`'s Settings access is limited to **Plans & Billing** and **Invoices**.
+## Agent Network permissions
+
+| Area | Owner | Admin | Auditor | Agent Network Admin | Usage Viewer | User |
+| --: | :--: | :--: | :--: | :--: | :--: | :--: |
+| Providers | ✅ | ✅ | 📖 | ✅ | 📖
3 | ❌ |
+| Policies & Guardrails | ✅ | ✅ | 📖 | ✅ | ❌ | ❌ |
+| Global Limits | ✅ | ✅ | 📖 | ✅ | ❌ | ❌ |
+| Usage | ✅ | ✅ | 📖 | ✅ | 📖 | 📖
4 |
+| Access Logs | ✅ | ✅ | 📖 | ✅ | 📖 | 📖
4 |
+| Configuration | ✅ | ✅ | 📖 | ✅ | ❌ | ❌ |
+
+`Network Admin` and `Billing Admin` have no Agent Network access beyond what every user gets.
+
+
3 A `Usage Viewer` sees the provider list with connection config redacted: no upstream URLs and no operator-supplied header values.
+
+
4 Every user, whatever their role, sees their own usage and their own requests on **Usage & Logs**.
+
## Owner
The `Owner` has full access to the account and can manage every aspect of it. There can be only one account owner in NetBird. Owners are the only users who can delete the organization account - see [Delete NetBird account](/manage/settings/delete-account) for more.
@@ -49,8 +66,21 @@ An `Auditor` can read every configuration in the account but can't modify anythi
## User
A `User` has limited access: they can view the peers they own and other peers they're allowed to connect to.
+## Agent Network Admin
+An `Agent Network Admin` has full control over Agent Network: providers, policies and guardrails, global limits, usage, access logs, and the configuration pages. They have read-only access to users, groups, peers, and account information, which they need to build policies. They have no access to any other part of the account, such as Control Center, Access Control, Network Routing, DNS, Setup Keys, or Reverse Proxy. The **Clusters** tab under Agent Network configuration stays hidden for this role because it requires Reverse Proxy permissions.
+
+## Usage Viewer
+A `Usage Viewer` has read-only access to the Agent Network **Usage** overview and to the account-wide **Access Logs**. They can also read the provider list, with connection config redacted, and users, groups, and peers, which the filters and name columns rely on. They have no access to policies, guardrails, global limits, or Agent Network configuration.
+
+
+ When [prompt collection](/agent-network/usage-and-logs/log-collection) is enabled, access logs contain the captured prompts and completions of every user. Granting `Usage Viewer` lets that user read other users' prompts.
+
+
+## Agent Network access for every user
+Any signed-in user, whatever their role, sees their own usage and their own requests on **Usage & Logs**. Once an Agent Network policy covers them, they also see the **Connect** page, which lists the providers and models their policies allow.
+
## Roles and the API
-Roles apply the same way whether a user works in the dashboard or through the [NetBird management API](/api) - a user's permissions over API resources match their role. Every role except `User` can create a personal access token that carries the same permissions as that user's role, so they can interact with the API programmatically.
+Roles apply the same way whether a user works in the dashboard or through the [NetBird management API](/api) - a user's permissions over API resources match their role. Every role except `User`, `Agent Network Admin`, and `Usage Viewer` can create a personal access token that carries the same permissions as that user's role, so they can interact with the API programmatically.
## Roles and identity provider sync
@@ -62,7 +92,7 @@ To change a user's role, go to the `Team` tab, select the `Users` tab, and click
-Select the desired role from the dropdown:
+Select the desired role from the dropdown. `Agent Network Admin` and `Usage Viewer` are under the **Agent Network** tab:
diff --git a/src/pages/selfhosted/enterprise/external-relays.mdx b/src/pages/selfhosted/enterprise/external-relays.mdx
new file mode 100644
index 00000000..f5a7da96
--- /dev/null
+++ b/src/pages/selfhosted/enterprise/external-relays.mdx
@@ -0,0 +1,541 @@
+export const description = 'Move relay and STUN off your NetBird Enterprise server onto dedicated hosts, step by step, without breaking traffic event logging.'
+
+# External Relays on a Commercial License
+
+import {Note, Warning} from "@/components/mdx";
+
+Your NetBird Enterprise server runs management, signal, relay and STUN in one `netbird-server` container. That works until relayed traffic starts competing with the control plane for the same host, or until users in another region relay through a server on the wrong continent. This guide moves the relay and STUN services onto dedicated hosts and points your server at them.
+
+One thing makes Enterprise different, and it is the reason this page exists rather than the [community guide](/selfhosted/maintenance/scaling/set-up-external-relays). When traffic flow is enabled, your deployment has a single shared secret doing **two** jobs: authenticating peers to the relay, and authenticating peers to the traffic-flow receiver. That is true however the deployment was built, whether installed fresh or migrated up from the community edition. Generate a new secret while following a guide that only knows about the first job, and your peers keep working perfectly while traffic event logging silently stops. Step 2 is where that is handled.
+
+## Before you start
+
+For each relay server you need:
+
+- A Linux VM with at least **1 CPU** and **1 GB RAM**
+- A public IP address
+- A domain name pointing at it, for example `relay-us.example.com`
+- Docker and the Compose plugin installed
+- These inbound ports open: **443/tcp** and **443/udp** (relay), **3478/udp** (STUN)
+
+
+**No inbound port 80 is needed.** A relay proves its domain to Let's Encrypt over 443 itself, using the TLS-ALPN-01 challenge. Your main NetBird server is different and does still need 80, and so may a proxy in front of the relay.
+
+**Both 443 rules matter.** The relay serves two transports on that port: WebSocket over TCP and QUIC over UDP. Clients race both and keep whichever answers first. With 443/udp closed the QUIC attempt runs until it times out, WebSocket carries the connection, and nothing reports an error, so the missing rule is easy to overlook.
+
+
+On the main server you need shell access to your deployment directory, which this guide calls `~/netbird-enterprise`, and the ability to restart containers there.
+
+This guide uses two relay servers, `relay-us.example.com` and `relay-eu.example.com`, and a main server at `netbird.example.com`. Substitute your own names throughout. One relay works fine; two is the smallest setup that shows the failover behavior.
+
+### Which names your deployment uses
+
+A deployment installed fresh with `getting-started-enterprise.sh` and one migrated up from the community edition with `migrate-to-enterprise.sh` use different names for the same things, and traffic flow is optional in both. On the main server, run:
+
+```bash
+cd ~/netbird-enterprise
+ls config.yaml.enterprise
+docker compose config --services | grep -E '^(receiver|flow-receiver)$'
+```
+
+| | Fresh install | Migrated from the community edition |
+|---|---|---|
+| Server configuration | `config.yaml` | `config.yaml.enterprise` if `ls` lists it, otherwise `config.yaml` |
+| Traffic-flow receiver service | `receiver` | `flow-receiver` |
+| Receiver container | `netbird-receiver` | `netbird-flow-receiver` |
+| Relay secret in `.env` | `NETBIRD_RELAY_AUTH_SECRET` | `NB_FLOW_AUTH_SECRET`, only if traffic flow was enabled |
+
+The commands on this page use the fresh-install names. On a migrated deployment, use the names from the second column instead. The configuration file matters most. `migrate-to-enterprise.sh` writes `config.yaml.enterprise` whenever it moves storage to PostgreSQL or enables traffic flow, and starts the server with that file instead of `config.yaml`. On such a deployment an edit to `config.yaml` has no effect.
+
+If the `grep` prints nothing, your deployment has no traffic-flow receiver. Skip the traffic event check in Step 1, all of Step 8, and the receiver commands in [Rotating to a new secret](#rotating-to-a-new-secret). Everything else on this page applies unchanged.
+
+## Step 1: Before you change anything
+
+**This is a cutover, not a gradual migration.** The moment you restart the server with the new configuration, the embedded relay stops and every peer that was using it reconnects to the external relays. There is no window where both are serving. So build and test the relay hosts completely, through the end of Step 5, before you touch the main server's configuration in Step 6.
+
+**Record whether traffic events are working now.** Step 8 checks that they survived the change, and that check can only tell you something if you know the answer beforehand. Generate a little traffic between two peers, for example `ping` one peer's NetBird IP from another, wait about 90 seconds, then on the main server run:
+
+```bash
+cd ~/netbird-enterprise
+docker compose logs --since 120s receiver | grep -c 'sending event'
+```
+
+Use `flow-receiver` on a migrated deployment, and skip this check if your deployment has no receiver ([Which names your deployment uses](#which-names-your-deployment-uses)).
+
+If it is `0` and the receiver still logs at its default level (`NB_FLOW_LOG_LEVEL` unset or `debug`), traffic events are not flowing **yet**, and that has nothing to do with relays. Traffic event logging is disabled by default at the account level even when the installer enabled traffic flow on the server: in the dashboard, go to `Settings > Networks` and turn on `Enable Traffic Events` under the Experimental section. See [Traffic Events Logging](/manage/activity/traffic-events-logging). Either enable it and re-run the check, or accept that Step 8 will have nothing to compare against.
+
+## Step 2: Read your existing shared secret
+
+**Do not generate a new secret.** Your deployment already has one, and the traffic-flow receiver, if you have one, uses the same value. Read it from the server configuration on the main server. The first line below picks `config.yaml.enterprise` when a migration created it, because that is the file the server reads, and the two files stop matching as soon as the secret is rotated:
+
+```bash
+cd ~/netbird-enterprise
+CONFIG=config.yaml; [ -f config.yaml.enterprise ] && CONFIG=config.yaml.enterprise
+grep 'authSecret:' "$CONFIG"
+```
+
+Read it from the server configuration and not from `.env`. The server configuration carries this value on every deployment. `.env` does not: a deployment created by `getting-started-enterprise.sh` has it there as `NETBIRD_RELAY_AUTH_SECRET`, one migrated up from the community edition with traffic flow has `NB_FLOW_AUTH_SECRET` instead, and one migrated without traffic flow has neither, because the community installer never wrote the relay secret to `.env` in the first place.
+
+That value goes in two places later: `NB_AUTH_SECRET` on every relay host in Step 3, and `relays.secret` on the main server in Step 6.
+
+
+`config.yaml` and `.env` are mode `600` and hold every secret in your deployment. Read the single line you need rather than opening the whole file, and keep the value out of shared terminal history.
+
+
+If your policy requires a fresh secret, that works too, but it has more places to change than you would expect. Use [Rotating to a new secret](#rotating-to-a-new-secret) at the end of this page rather than improvising here.
+
+## Step 3: Create the relay configuration
+
+Do this on each relay server.
+
+```bash
+mkdir -p ~/netbird-relay
+cd ~/netbird-relay
+```
+
+Create `relay.env`. The relay can obtain and renew its own TLS certificate through Let's Encrypt:
+
+```bash
+NB_LOG_LEVEL=info
+NB_LISTEN_ADDRESS=:443
+NB_EXPOSED_ADDRESS=rels://relay-us.example.com:443
+NB_AUTH_SECRET=the-value-from-step-2
+
+# TLS via Let's Encrypt
+NB_LETSENCRYPT_DOMAINS=relay-us.example.com
+NB_LETSENCRYPT_EMAIL=admin@example.com
+NB_LETSENCRYPT_DATA_DIR=/data/letsencrypt
+
+# Embedded STUN (comma-separated for multiple ports, e.g. 3478,3479)
+NB_ENABLE_STUN=true
+NB_STUN_PORTS=3478
+```
+
+Replace `relay-us.example.com` with this host's own domain and `the-value-from-step-2` with the secret you read. The file holds that secret, so make it readable by root only:
+
+```bash
+chmod 600 relay.env
+```
+
+The relay reads `relay.env` when its container is created. If you change the file later on a running relay, apply it with `docker compose up -d`. `docker compose restart` keeps the old values.
+
+Create `docker-compose.yml`:
+
+```yaml
+services:
+ relay:
+ image: netbirdio/relay:latest
+ container_name: netbird-relay
+ restart: unless-stopped
+ ports:
+ # Both transports. Docker reads a bare '443:443' as TCP only,
+ # so the UDP line is required for QUIC.
+ - '443:443/tcp'
+ - '443:443/udp'
+ # Expose every port listed in NB_STUN_PORTS
+ - '3478:3478/udp'
+ env_file:
+ - relay.env
+ volumes:
+ - relay_data:/data
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "500m"
+ max-file: "2"
+
+volumes:
+ relay_data:
+```
+
+The relay image is the open source `netbirdio/relay`. A relay host takes no license key.
+
+### If you already have TLS certificates
+
+To use your own certificate instead of Let's Encrypt, delete the three `NB_LETSENCRYPT_*` lines from `relay.env` and add:
+
+```bash
+NB_TLS_CERT_FILE=/certs/fullchain.pem
+NB_TLS_KEY_FILE=/certs/privkey.pem
+```
+
+Then replace the relay service's `volumes:` list in `docker-compose.yml` with this one. Do not add it as a second `volumes:` key, which makes the file invalid:
+
+```yaml
+ volumes:
+ - /path/to/certs:/certs:ro
+ - relay_data:/data
+```
+
+`fullchain.pem` is your certificate followed by any intermediates, and the certificate must cover the relay's domain. Four things catch people out:
+
+- **Set both variables, and remove every `NB_LETSENCRYPT_*` line.** If a Let's Encrypt line remains, the relay uses Let's Encrypt and ignores your files. If only one `NB_TLS_*` variable is set, the relay starts with no TLS at all. Either way, the sign is a missing QUIC line in Step 4.
+- **Nothing in the startup log names your certificate.** Step 5's `curl -v` is how you confirm it is the one being served.
+- **The files are read once, at startup.** Restart the relay after every renewal: `docker compose restart relay`.
+- **A private CA must be trusted by every peer.** NetBird checks the relay's certificate against each device's operating-system trust store, and has no setting for a separate CA. A device that does not trust yours reports the relay as `Unavailable` with `x509: certificate signed by unknown authority`, and cannot relay to other peers. Distribute the CA to every device before you switch.
+
+### If a load balancer or proxy sits in front of the relay
+
+In this shape your proxy holds the certificate and the relay serves plain HTTP behind it.
+
+Use this shape only because a load balancer or proxy is already in front of your relays. Do not add one just to terminate TLS on the relay host: the relay already does that itself, and a proxy in front of it costs you QUIC.
+
+**On the relay host**, `relay.env` has no `NB_LETSENCRYPT_*` lines, listens on an internal port, and names the proxy's address:
+
+```bash
+NB_LOG_LEVEL=info
+NB_LISTEN_ADDRESS=:8080
+NB_EXPOSED_ADDRESS=rels://relay-us.example.com:443
+NB_AUTH_SECRET=the-value-from-step-2
+NB_TRUSTED_PROXIES=10.20.0.10
+NB_ENABLE_STUN=true
+NB_STUN_PORTS=3478
+```
+
+Keep `rels://` written out in `NB_EXPOSED_ADDRESS`. With no TLS of its own, the relay would otherwise advertise `rel://`.
+
+In `docker-compose.yml`, remove the two `443` port lines and keep `3478:3478/udp` for STUN. Make port 8080 reachable from the proxy and nothing else. If the proxy is on another machine, publish the port with `'8080:8080/tcp'` and allow it at your firewall from the proxy's address only.
+
+**Your proxy must:**
+
+- hold a certificate for the relay's domain, and accept connections on 443/tcp;
+- forward WebSocket upgrades over HTTP/1.1 to the relay's port 8080;
+- set both `X-Real-Ip` and `X-Real-Port` to the address and source port of the connection it received, overwriting anything the client sent.
+
+The relay uses those two headers only when the connection comes from an address in `NB_TRUSTED_PROXIES`, and only when both are present. A proxy that sets only `X-Real-Ip` gets its own address logged for every peer. A proxy that passes the client's headers through lets any client write the address of its choice into your relay logs.
+
+`NB_TRUSTED_PROXIES` is the address the relay sees the proxy's connections come from, which is not always the proxy's public address. When the proxy and relay share a Docker network, it is the proxy container's address on that network. When the proxy is another machine on the same private network, it is that machine's private address: a published Docker port keeps the original source address.
+
+
+Trust only the smallest proxy ranges you control. Never `0.0.0.0/0`, `::/0`, or a shared network where an untrusted system could reach the relay directly and forge these headers. Entries are IP addresses or CIDRs separated by commas. Hostnames are rejected, and a prefix such as `10.20.0.10/24` trusts the whole `/24`.
+
+
+These headers decide only which client address the relay records, including in the `invalid signature` lines used for troubleshooting. Authentication does not depend on them.
+
+Apply a change to `NB_TRUSTED_PROXIES` with `docker compose up -d relay`. An invalid entry stops the relay: it exits with `failed to parse trusted proxies: ...` and restarts in a loop until you fix it. To confirm the setting works, check that the relay logs your peers' addresses rather than the proxy's:
+
+```bash
+docker compose logs relay | grep 'WS client connected from'
+```
+
+The CLI equivalent of `NB_TRUSTED_PROXIES` is `--trusted-proxies`, available since **v0.75.0**.
+
+At startup the relay reports that it cannot serve QUIC:
+
+```
+WARN relay/server/server.go:78: Not starting QUIC listener: valid TLS config is required for QUIC listener
+```
+
+That is expected here. Peers connect over WebSocket, so neither the proxy nor the relay host needs 443/udp. The proxy needs 443/tcp, plus whatever it uses to obtain its own certificate, often 80/tcp. The relay host needs 3478/udp for STUN.
+
+## Step 4: Start each relay
+
+```bash
+docker compose up -d
+docker compose logs -f
+```
+
+You should see the relay announce its address, both listeners, and the STUN server:
+
+```
+INFO relay/cmd/root.go:242: server will be available on: rels://relay-us.example.com:443
+INFO relay/server/listener/ws/listener.go:51: WS server listening address: :443
+INFO relay/server/listener/quic/listener.go:39: QUIC server listening on address: :443
+INFO [component: stun] stun/server.go:71: STUN server listening on [::]:3478
+```
+
+Other lines appear alongside these, including the Let's Encrypt setup, the health check server and the metrics server. The order changes from one start to the next, because the listeners come up concurrently. What matters is that all four lines are present, not where they sit.
+
+A missing QUIC line means the relay has no TLS configuration of its own, which is expected only behind a TLS-terminating proxy.
+
+## Step 5: Check each relay from outside
+
+Certificates are issued lazily on the first request, so this both provisions and verifies:
+
+```bash
+curl -v https://relay-us.example.com/
+```
+
+A `404 page not found` is the correct response. What matters is that the TLS handshake succeeded:
+
+```
+* Server certificate:
+* subject: CN=relay-us.example.com
+* issuer: C=US; O=Let's Encrypt; CN=E8
+* SSL certificate verify ok.
+```
+
+The issuer's `CN` names whichever intermediate signed your certificate, so yours will often differ.
+
+Two things can go wrong here, and they look different. If the first attempt fails with a TLS error such as `SSL_ERROR_SYSCALL`, wait a few seconds and run it again: that first request is what triggers issuance, and it can time out while that happens.
+
+If instead the command hangs with no output and no error, issuance is stuck rather than slow. The relay reports the reason and `curl` cannot:
+
+```bash
+docker compose logs relay | grep -i acme
+```
+
+A rate limit is the likeliest cause if you have rebuilt the same relay host several times. Let's Encrypt allows five certificates per week for one exact set of domain names, and the error names the time it frees up again.
+
+Repeat Steps 3 to 5 on every relay server, using the **same** `NB_AUTH_SECRET` and that host's own domain name.
+
+Do not continue until every relay answers. Step 6 is the cutover.
+
+## Step 6: Point the main server at the relays
+
+On the main server, take a copy of the current configuration first, then edit it:
+
+```bash
+cd ~/netbird-enterprise
+cp config.yaml config.yaml.bak
+nano config.yaml
+```
+
+On a migrated deployment that has `config.yaml.enterprise`, copy and edit that file instead. It is the one the server reads, and changes made to `config.yaml` never reach it.
+
+The backup matters because a mistake here does not announce itself: the deployment keeps working and only traffic event logging stops.
+
+Add `stuns` and `relays` inside the existing `server:` block, at the same indentation as the keys already there. Anywhere inside that block works; the example below puts them after `authSecret` to keep the relay settings together. Everything else already under `server:` stays exactly as it is, including the `auth`, `reverseProxy`, `store`, `activityStore` and `trafficFlow` sections a `getting-started-enterprise.sh` deployment carries. Setting `relays.addresses` is what turns the embedded relay off, and it turns the embedded STUN server off too, which is why `stuns` is required:
+
+```yaml
+server:
+ listenAddress: ":80"
+ exposedAddress: "https://netbird.example.com:443"
+
+ # Leave authSecret exactly as it is. relays.addresses below is what
+ # disables the embedded relay.
+ authSecret: "your-existing-secret"
+
+ # External STUN servers, one per relay host
+ stuns:
+ - uri: "stun:relay-us.example.com:3478"
+ proto: "udp"
+ - uri: "stun:relay-eu.example.com:3478"
+ proto: "udp"
+
+ # External relay servers
+ relays:
+ addresses:
+ - "rels://relay-us.example.com:443"
+ - "rels://relay-eu.example.com:443"
+ secret: "the-value-from-step-2"
+ credentialsTTL: "24h"
+
+ # ... the rest of your existing configuration is unchanged
+```
+
+
+`relays.secret` must be the value you read in Step 2. It must match `NB_AUTH_SECRET` on every relay host and, if your deployment has a traffic-flow receiver, the receiver's `NB_FLOW_AUTH_SECRET`. The two mismatches fail differently:
+
+- **With a relay:** that relay rejects peers, and its log says `invalid signature` (see [Troubleshooting](#troubleshooting)).
+- **With the receiver:** relaying keeps working, and traffic events stop. Only the receiver's log shows it. Step 8 checks for exactly this.
+
+
+Leave `server.authSecret` in place. It is required only when the embedded relay is running, and removing it gains you nothing.
+
+`stuns` and `relays` are available since **v0.65.0**.
+
+## Step 7: Restart the server
+
+`config.yaml` is a bind mount, so editing it changes nothing Docker Compose compares. `docker compose up -d` reports the service as already up to date and keeps serving the old configuration. Restart it explicitly:
+
+```bash
+cd ~/netbird-enterprise
+docker compose restart netbird-server
+```
+
+
+Do not run `docker compose down` to apply this change. On an Enterprise deployment that also stops Traefik, PostgreSQL, NATS and the traffic-flow services. Restarting the one container you changed is enough.
+
+
+Confirm the embedded relay is off and your addresses are reaching clients:
+
+```bash
+docker compose logs --tail 400 netbird-server | grep -E 'Relay:|Relay addresses' | tail -3
+```
+
+If the server has been restarted before, its log holds a block like this for every restart. The `tail` keeps you looking at the most recent one, which is the only one that reflects the change you just made.
+
+```
+INFO combined/cmd/root.go:691: Relay: false (log level: )
+INFO combined/cmd/root.go:731: Relay addresses: [rels://relay-us.example.com:443 rels://relay-eu.example.com:443]
+INFO combined/cmd/config.go:806: Relay addresses: [rels://relay-us.example.com:443 rels://relay-eu.example.com:443]
+```
+
+The address list is logged twice, from two different places in the server. That is normal.
+
+
+Then check a peer:
+
+```bash
+netbird status -d
+```
+
+```
+Relays:
+ [stun:relay-us.example.com:3478] is Available
+ [stun:relay-eu.example.com:3478] is Available
+ [rels://relay-eu.example.com:443] is Available via ws
+```
+
+Every STUN server you configured appears. Relays behave differently: the client dials them in parallel and keeps the first to answer, normally the nearest, and that one becomes its **home relay**. A second `rels://` line is also normal, because a client connects to another peer's home relay when that peer picked a different one.
+
+The suffix names the transport that won the race, and it is `ws` or `quic` depending on which answered first. Both are normal, the winner can differ between two peers on the same deployment, and neither is a sign of a problem. What 443/udp buys you is that the QUIC attempt can win or lose on merit rather than sitting until it times out.
+
+## Step 8: Confirm traffic events survived
+
+**Do not skip this.** It is the check that belongs to Enterprise, and the failure it catches is invisible everywhere else: peers stay connected, the dashboard shows no error, and only the traffic event stream stops.
+
+It applies only to a deployment with a traffic-flow receiver. Without one there are no traffic events to lose, and you are done.
+
+Generate a little traffic between two peers again, `ping` being enough, wait about 90 seconds for the client's reporting interval, then look for rejected tokens on the main server:
+
+```bash
+cd ~/netbird-enterprise
+docker compose logs --since 120s receiver | grep -c 'invalid signature'
+```
+
+On a migrated deployment the service is `flow-receiver`, here and in the next command.
+
+This must be `0`.
+
+If it is not, `relays.secret` and the receiver's `NB_FLOW_AUTH_SECRET` do not match. Either side can be the stale one:
+
+- **`relays.secret` is wrong.** Go back to Step 2, re-read the value from the server configuration, and correct `relays.secret` and `NB_AUTH_SECRET` on every relay host to match it.
+- **The receiver still holds an old value**, typically after a rotation applied with `restart`. Check the receiver's value as shown in [Rotating to a new secret](#rotating-to-a-new-secret). Make sure `.env` holds the same value as `relays.secret`, then recreate the receiver with `docker compose up -d receiver` (`flow-receiver` on a migrated deployment).
+
+While they differ, the receiver log shows:
+
+```
+ERRO server/auth.go:127: invalid token validation: invalid signature
+```
+
+and your peers retry in a log nobody watches:
+
+```
+ERRO flow/client/client.go:287: flow receiver sent no headers
+ERRO flow/client/client.go:143: failed to establish flow stream, retrying: check header: should have headers
+ERRO client/internal/netflow/manager.go:228: failed to send flow event to server: stream not initialized
+```
+
+For a positive signal, count the events the receiver accepted:
+
+```bash
+docker compose logs --since 120s receiver | grep -c 'sending event'
+```
+
+Greater than zero confirms events are arriving. The two checks answer different questions. `0` for `invalid signature` proves the secret is right, which is the part this page can break, but not that events arrive: the receiver can stop accepting them for other reasons. Only a count above zero proves that. The receiver logs these lines at debug level, which is its default (`NB_FLOW_LOG_LEVEL`). If you have raised its log level, this count stays at `0` even when events arrive, so the check is not complete until the events show up on the Traffic Events page in the dashboard.
+
+
+Check the receiver's log rather than the Traffic Events page in the dashboard. Events pass through the enricher and PostgreSQL before they appear there, so the dashboard lags well behind and cannot tell you whether events are arriving right now.
+
+
+## Testing failover
+
+Stop the relay a peer is actually using, which is the one on its `rels://` line, otherwise nothing observable changes. Then check that peer again:
+
+```bash
+netbird status -d
+```
+
+**Read the `rels://` line, not the `stun:` lines.** It is the one that shows failover working: the stopped relay drops out of it and the surviving relay carries the traffic.
+
+```
+ [rels://relay-eu.example.com:443] is Available via ws
+```
+
+The `stun:` entries are not a dependable failover signal. Depending on circumstances they either report the stopped server as `Unavailable` with a reason, or sit at `Checking...` for every configured STUN server, including ones that are perfectly healthy, without clearing on their own or after a client restart. Both have been observed. Either way they return to `Available` once the stopped relay is back, so do not read `Checking...` as a second failure.
+
+To prove relayed traffic end to end, force a peer to relay instead of connecting directly:
+
+```bash
+sudo netbird service reconfigure --service-env NB_FORCE_RELAY=true
+```
+
+Test connectivity to another peer, then put it back:
+
+```bash
+sudo netbird service reconfigure --service-env NB_FORCE_RELAY=false
+```
+
+## Rotating to a new secret
+
+If you would rather not reuse the existing secret, generate one and set it **everywhere that value appears**, not only where the relay needs it:
+
+```bash
+openssl rand -base64 32
+```
+
+1. `NB_AUTH_SECRET` in `relay.env` on every relay host.
+2. `relays.secret` in the server configuration on the main server (`config.yaml`, or `config.yaml.enterprise` on a migrated deployment that has it).
+3. `server.authSecret` in the same file.
+4. In `.env` on the main server, under whichever name your deployment uses: `NETBIRD_RELAY_AUTH_SECRET` on a fresh install, `NB_FLOW_AUTH_SECRET` on one migrated from the community edition with traffic flow. That is the value that reaches the traffic-flow receiver. A migrated deployment without traffic flow has no entry here.
+
+All four must end up holding the same value. Relaying needs 1 and 2 to match, and traffic events need 2 and 4 to match. Entry 3 is not used by either, but the product's own migration tooling treats `server.authSecret` as the value the receiver's secret must equal, so leaving it stale stores up a failure for a later upgrade.
+
+Then apply the change. On every relay host:
+
+```bash
+cd ~/netbird-relay
+docker compose up -d
+```
+
+and on the main server, before re-running Step 8:
+
+```bash
+cd ~/netbird-enterprise
+docker compose restart netbird-server
+docker compose up -d receiver
+```
+
+Use `flow-receiver` on a migrated deployment. With no receiver, the first command is all you need.
+
+The main server's two commands differ on purpose. `netbird-server` reads `config.yaml` through a bind mount, so restarting it is enough. The receiver takes its secret from `.env`, and a container's environment is fixed when the container is created, exactly as the relay's `relay.env` is: `docker compose restart receiver` brings back the **old** value and reports nothing wrong. Only `up -d`, which recreates the container, picks up the new one.
+
+Get that second command wrong and the symptom is exactly the one Step 8 is designed to catch, which will send you back to re-check four values that are already correct. If Step 8 still shows `invalid signature` after a rotation, confirm the receiver really took the new value:
+
+```bash
+docker exec netbird-receiver env | grep NB_FLOW_AUTH_SECRET
+```
+
+On a migrated deployment the container is `netbird-flow-receiver`.
+
+Miss the fourth entry, or apply it with `restart`, and you are back to silently losing traffic events.
+
+## Troubleshooting
+
+**A relay shows as `Unavailable` on a peer.** The reason string tells you which problem you have.
+
+```
+[rels://relay-us.example.com:443] is Unavailable, reason: failed to get reader: failed to read frame header: EOF
+```
+
+That one is a secret mismatch. Over QUIC the same rejection reads `reason: closed by server` instead, and you will see it on a peer only once every relay is rejecting: a peer lists only the relays it is using, so one bad relay among several is simply absent from the list rather than shown as failed. The relay host says so outright either way, in `docker compose logs relay`:
+
+```
+ERRO relay/server/relay.go:141: failed to handshake: validate sha-... (203.0.113.10:50907): invalid signature
+```
+
+Make `relays.secret` and every host's `NB_AUTH_SECRET` identical. If the relay logs no connection attempt at all, the problem is reachability instead: confirm the domain resolves, test with `nc -zv relay-us.example.com 443`, and check the certificate is valid.
+
+Other reason strings point away from the secret and towards reachability or the certificate. `relay client not connected` and `connect to relay server: context deadline exceeded` both mean the peer never completed a TLS session with the relay, so check Step 5 again on that host before touching any secret.
+
+`x509: certificate signed by unknown authority` in the reason means the peer does not trust the relay's certificate, which happens with a private CA the device has not been given. See [If you already have TLS certificates](#if-you-already-have-tls-certificates).
+
+When every relay is failing, each relay's entry can carry the errors of all of them, so a reason quoting one host's error may appear against another. The relay host's own log is the reliable record of which host rejected what.
+
+Either way the `stun:` entry for the same host stays `Available`, because STUN is unauthenticated, so it cannot tell you anything here.
+
+**STUN is not working.** Confirm 3478/udp is open and that `NB_ENABLE_STUN=true` is set. Some networks block outbound UDP, so try from a different network before changing the relay.
+
+**Traffic events stopped.** That is the secret mismatch in Step 8, not a relay fault.
+
+## When not to do this
+
+Splitting relays out costs hosts to patch, certificates to renew, and a secret to keep in step across machines. Do it when relayed traffic is loading your server, or when users are far enough away that a nearer relay is a real improvement. Do not do it just to make the deployment look tidier.
+
+If your aim is surviving the loss of the main server rather than spreading relay load, external relays do not get you there: management and signal are still on one host. That is what [Running a Highly Available Self-Hosted Deployment](/selfhosted/maintenance/scaling/high-availability) is for.
+
+## Summary
+
+Read the secret your deployment already has, build each relay host with it, prove every relay answers from outside, then point the server configuration at them and restart `netbird-server` alone. Finally, if your deployment has a traffic-flow receiver, check that it is still accepting flow events, because your peers will look perfectly healthy even when it is not.
diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx
index 1f2b9807..0f6ba1b2 100644
--- a/src/pages/selfhosted/enterprise/getting-started.mdx
+++ b/src/pages/selfhosted/enterprise/getting-started.mdx
@@ -39,6 +39,7 @@ curl -fsSL https://pkgs.netbird.io/getting-started-enterprise.sh | bash
The script prompts for:
- Whether to enable traffic flow — required for traffic event logging and streaming.
+- Whether to enable the [NetBird Proxy](/manage/reverse-proxy), and optionally [CrowdSec](/selfhosted/maintenance/crowdsec).
- The public NetBird domain.
- A single license key (used for all enabled products and features).
@@ -48,6 +49,10 @@ It then generates the deployment files, pulls the required images, starts Postgr
Enabling traffic flow adds NATS, a flow receiver, and a flow enricher to the stack. Traffic flow is required for traffic event logging and streaming.
+
+The NetBird Proxy needs a wildcard DNS record for your service domains (for example `*.netbird.example.com`) pointing at the host.
+
+
### 1.3 Generated files
The script writes the generated files into the current directory:
@@ -57,8 +62,9 @@ The script writes the generated files into the current directory:
| `.env` | Runtime configuration, license key, and generated secrets | `600` |
| `docker-compose.yml` | Compose stack for the NetBird server and optional traffic-flow services | `644` |
| `config.yaml` | NetBird server configuration (YAML) | `600` |
+| `traefik/` | Traefik dynamic configuration, including the proxy's `proxy.yaml` | `755` |
-Reverse proxy and automatic HTTPS are configured through Traefik labels and command flags inside `docker-compose.yml`, so there is no separate proxy configuration file.
+Reverse proxy and automatic HTTPS are configured through Traefik labels and command flags inside `docker-compose.yml`.
The script aborts if generated files already exist in the directory. This avoids overwriting secrets or replacing an existing deployment by accident.
@@ -94,6 +100,24 @@ Enabling traffic flow adds:
| `receiver` | `ghcr.io/netbirdio/flow-receiver-cloud:latest` | Traffic flow ingest |
| `enricher` | `ghcr.io/netbirdio/flow-enricher-cloud:latest` | Traffic flow enrichment |
+Enabling the NetBird Proxy adds:
+
+| Service | Image | Notes |
+| --- | --- | --- |
+| `proxy` | `ghcr.io/netbirdio/reverse-proxy:latest` | NetBird Proxy |
+| `crowdsec` | `crowdsecurity/crowdsec:v1.7.7` | Only when CrowdSec is enabled |
+
+### 1.7 Enable features on an existing installation
+
+Run the script with a flag from the installation directory:
+
+```bash
+curl -fsSL https://pkgs.netbird.io/getting-started-enterprise.sh | bash -s -- --enable-traffic-events
+curl -fsSL https://pkgs.netbird.io/getting-started-enterprise.sh | bash -s -- --enable-proxy
+```
+
+The script shows the changes to `docker-compose.yml` and asks before applying them. If a step fails, it restores the previous files. After `--enable-traffic-events`, turn on traffic events in the dashboard settings.
+
## 2. Connect Identity Providers
We recommend using SCIM provisioning where possible. In the following setup guides, you may **skip JWT group settings** and use our group syncing integration instead.
@@ -135,6 +159,8 @@ We recommend using SCIM provisioning where possible. In the following setup guid
Use `migrate-to-enterprise.sh` to convert an existing community combined-server deployment to the enterprise images. The same script also migrates SQLite data to Postgres and enables traffic flow, so no manual Compose or `config.yaml` edits are required for the supported migration path.
+For installations created with `getting-started-enterprise.sh`, use [1.7 Enable features on an existing installation](#1-7-enable-features-on-an-existing-installation) instead.
+
The script targets an existing combined-server deployment that has a `docker-compose.yml`, a bind-mounted `config.yaml`, and a persistent `/var/lib/netbird` data volume.
### 3.1 Prerequisites
@@ -268,7 +294,7 @@ Each entry follows the same structure: **Symptom → Cause → Resolution → Ve
- **Resolution:** Sign in with the existing owner credentials if available. To start over from scratch:
```bash
docker compose down --volumes # removes containers and the postgres/embedded-IdP data
- rm -f .env docker-compose.yml config.yaml
+ rm -rf .env docker-compose.yml config.yaml traefik
curl -fsSL https://pkgs.netbird.io/getting-started-enterprise.sh | bash
```
- **Verification:** `curl -s https://
/api/instance | jq .` returns `setupRequired: true`; the dashboard now shows the owner-setup flow on first visit.
@@ -317,6 +343,8 @@ If your Traefik already has a file provider, add this `tls` block to its existin
### Update `docker-compose.yml`
+Installations created by the current script already have the file provider and the `./traefik` mount, so only add the certificate mount and remove ACME.
+
**Enable the file provider.** Add this to the `traefik` service's `command:` list:
```yaml
@@ -370,6 +398,26 @@ echo | openssl s_client -connect :443 -servername 2>/
- The certificate must cover `NETBIRD_DOMAIN` from `.env`. A wildcard like `*.example.com` works for `netbird.example.com`.
- **If the certificate is signed by a private CA, every peer must trust the issuing CA.** Install the CA bundle in each peer's system trust store. A peer that does not trust it fails to connect at all, logging `x509: certificate signed by unknown authority`.
+
+ The NetBird Proxy container is a peer too. Without the CA it still registers as online, but it cannot connect to the Signal service, so requests to its services fail.
+
+ Give the proxy a CA bundle that holds the host's public roots and your CA. Add it in `docker-compose.override.yml`, not `docker-compose.yml`: running `getting-started-enterprise.sh --enable-proxy` or `--enable-traffic-events` regenerates `docker-compose.yml`, so a hand edit there does not survive. On Debian and Ubuntu hosts:
+
+ ```bash
+ cat /etc/ssl/certs/ca-certificates.crt /path/to/your-ca.pem | sudo tee /etc/netbird/certs/proxy-ca-bundle.pem > /dev/null
+ ```
+
+ ```yaml
+ services:
+ proxy:
+ environment:
+ - SSL_CERT_FILE=/etc/ssl/netbird/ca-bundle.pem
+ volumes:
+ - /etc/netbird/certs/proxy-ca-bundle.pem:/etc/ssl/netbird/ca-bundle.pem:ro
+ ```
+
+ Then run `docker compose up -d proxy`.
- **Renewals.** Replace both files at the same paths, then `touch traefik/dynamic.yaml`. Traefik watches the dynamic configuration, not the certificates it references, so replacing them alone has no effect. Touching it reloads them with no container restart.
- You can leave the `netbird_traefik_letsencrypt` volume in place. With no resolver configured, the stored certificate is inert.
+- `--enable-proxy` and `--enable-traffic-events` keep this setup.
- Dropping the `80:80` mapping does not affect certificates: this deployment validates over TLS-ALPN-01 on port 443 and never uses port 80 for ACME. You do lose the HTTP→HTTPS redirect.
diff --git a/src/pages/selfhosted/enterprise/index.mdx b/src/pages/selfhosted/enterprise/index.mdx
index 61298ff3..37d4ca20 100644
--- a/src/pages/selfhosted/enterprise/index.mdx
+++ b/src/pages/selfhosted/enterprise/index.mdx
@@ -33,6 +33,8 @@ The core connectivity, access control, and routing features are the same in the
| SCIM user and group provisioning | No | Yes |
| EDR and MDM integrations (CrowdStrike, SentinelOne, Intune, and others) | No | Yes |
| Traffic-flow event logging and streaming | No | Yes |
+| Peer approval | No | Yes |
+| Audit event streaming and notifications | No | Yes |
| Support | Community (Slack, GitHub) | Standard support included |
## Frequently asked questions
@@ -73,7 +75,7 @@ To serve multiple isolated customers you have two options:
You can start evaluating today for free: the **open source Community Edition is free to self-host for as long as you like**, with no license and no time limit. That is the right way to try core NetBird (the control plane, peers, policies, and routing) while you decide whether it fits.
-To evaluate the commercial-only features (active-active HA, SCIM, traffic-flow logging), contact the sales team through the [on-prem pricing page](https://netbird.io/pricing#on-prem) to run an assisted proof of concept with the commercial license. You install and run the stack; NetBird provides the license and guidance. A commercial proof of concept runs for 30 days by default.
+To evaluate the commercial-only features, contact the sales team through the [on-prem pricing page](https://netbird.io/pricing#on-prem) to run an assisted proof of concept with the commercial license. A proof of concept includes every feature of the commercial license, including everything marked commercial in the table above. You install and run the stack; NetBird provides the license and guidance. A commercial proof of concept runs for 30 days by default.
### What does the server share with the NetBird license service?
diff --git a/src/pages/selfhosted/environment-variables.mdx b/src/pages/selfhosted/environment-variables.mdx
index 9bf985b1..246e7d30 100644
--- a/src/pages/selfhosted/environment-variables.mdx
+++ b/src/pages/selfhosted/environment-variables.mdx
@@ -54,9 +54,9 @@ The default quickstart deployment uses the relay service's embedded STUN server.
| Variable | Default | Description |
|----------|---------|-------------|
-| `NETBIRD_STORE_CONFIG_ENGINE` | `sqlite` | Store engine: `sqlite`, `postgres`, or `mysql` |
+| `NETBIRD_STORE_CONFIG_ENGINE` | `sqlite` | Store engine: `sqlite`, `postgres`, or `mysql` (deprecated) |
| `NETBIRD_STORE_ENGINE_POSTGRES_DSN` | - | PostgreSQL connection string |
-| `NETBIRD_STORE_ENGINE_MYSQL_DSN` | - | MySQL connection string |
+| `NETBIRD_STORE_ENGINE_MYSQL_DSN` | - | MySQL connection string. Deprecated, see [Migrate from MySQL to PostgreSQL](/selfhosted/migration/mysql-to-postgresql) |
| `NETBIRD_DATASTORE_ENC_KEY` | Auto-generated | Encryption key for sensitive data |
diff --git a/src/pages/selfhosted/identity-providers/managed/advanced/google-workspace.mdx b/src/pages/selfhosted/identity-providers/managed/advanced/google-workspace.mdx
index a9bb0bc1..b8832d03 100644
--- a/src/pages/selfhosted/identity-providers/managed/advanced/google-workspace.mdx
+++ b/src/pages/selfhosted/identity-providers/managed/advanced/google-workspace.mdx
@@ -3,26 +3,30 @@ import {Note, Warning} from "@/components/mdx";
# Google Workspace SSO with NetBird Self-Hosted (Legacy)
-The client secret has been removed from the authentication flow. The secret was originally included to support providers like Google Workspace that don't offer a proper PKCE flow, but this is no longer necessary with the embedded IdP.
+**Using Google directly as NetBird's identity provider is not recommended.** Use the [embedded IdP with Google as an external provider](/selfhosted/identity-providers/managed/google-workspace#management-setup-recommended) instead. If you already run this standalone setup, migrate with the [External IdP to Embedded IdP migration guide](/selfhosted/migration/external-to-embedded-idp).
-If you are using this standalone Google Workspace setup, we strongly recommend migrating to the [embedded IdP setup with Google as an external provider](/selfhosted/identity-providers/managed/google-workspace#management-setup-recommended). The embedded IdP approach is simpler, fully supported, and does not rely on the deprecated client secret.
+Google requires a client secret to issue tokens, even with PKCE, so this setup relies on the Management service sending the client secret to every NetBird client. Sending the client secret to clients is deprecated and will be removed in a future release. With the embedded IdP, clients authenticate against NetBird's own IdP, which brokers the login to Google and keeps the client secret on the server.
Use Google accounts for authentication with NetBird. This supports both personal Google accounts and Google Workspace (formerly G Suite) organizations.
-## Standalone Setup (Advanced)
+## Standalone Setup (Not Recommended)
NetBird includes built-in [local user management](/selfhosted/identity-providers/local) powered by an embedded IdP, allowing you to create and manage users directly without requiring an external identity provider. You can also add **multiple external identity providers** alongside local users, giving users multiple login options.
We highly recommend using the simpler setup that adds Google as an external IdP directly in the NetBird Management Dashboard. This approach requires minimal configuration, works alongside local users, and doesn't require replacing your embedded IdP. See the [Management Setup (Recommended)](/selfhosted/identity-providers/managed/google-workspace#management-setup-recommended) section in the main Google Workspace documentation.
-The standalone setup below replaces your embedded IdP entirely and is only recommended for experienced Google Workspace administrators who need full control over authentication and user management.
+The standalone setup below replaces your embedded IdP entirely and is not recommended for new or existing deployments.
-Use Google Workspace as your primary identity provider instead of NetBird's embedded IdP. This option gives you full control over authentication and user management, is recommended for experienced Google Workspace administrators as it also requires additional setup and ongoing maintenance.
+Use Google Workspace as your primary identity provider instead of NetBird's embedded IdP. This option requires additional setup and ongoing maintenance, depends on the deprecated client secret delivery described above, and has the limitations listed below.
-For most deployments, the [embedded IdP](/selfhosted/identity-providers/local) is the simpler choice — it's built into NetBird, fully integrated, and requires minimal configuration to get started. For this implementation, go back up to the [Management Setup (Recommended)](#management-setup-recommended) section above.
+For most deployments, the [embedded IdP](/selfhosted/identity-providers/local) is the simpler choice — it's built into NetBird, fully integrated, and requires minimal configuration to get started. For this implementation, see the [Management Setup (Recommended)](/selfhosted/identity-providers/managed/google-workspace#management-setup-recommended) section in the main Google Workspace documentation.
+
+### Limitation: No Device Authorization Flow
+
+Google's device flow requires a client secret when polling for the token, and NetBird clients do not send one, so this setup disables the device flow (`NETBIRD_AUTH_DEVICE_AUTH_PROVIDER="none"` in [Step 7](#step-7-configure-net-bird)). Linux and FreeBSD peers without a graphical session, such as servers or SSH sessions, use only the device flow, so they cannot log in with SSO (`netbird login`, `netbird up`) or authenticate with `netbird ssh`. Register those peers with [setup keys](/manage/peers/register-machines-using-setup-keys), or migrate to the embedded IdP, which supports the device flow.
Beginning with NetBird version v0.23.6 and onwards, the Google Workspace IdP manager no longer requires the custom admin role called `User and Schema Management`. We now use a read-only role for user information.
diff --git a/src/pages/selfhosted/identity-providers/managed/google-workspace.mdx b/src/pages/selfhosted/identity-providers/managed/google-workspace.mdx
index 8c0445e5..1f5b13ba 100644
--- a/src/pages/selfhosted/identity-providers/managed/google-workspace.mdx
+++ b/src/pages/selfhosted/identity-providers/managed/google-workspace.mdx
@@ -1,4 +1,4 @@
-import {Note} from "@/components/mdx";
+import {Note, Warning} from "@/components/mdx";
# Google Workspace SSO with NetBird Self-Hosted
@@ -138,13 +138,15 @@ Domain restrictions are configured in Google Cloud Console, not in NetBird.
---
-## Standalone Setup (Advanced)
+## Standalone Setup (Not Recommended)
-Use Google Workspace as your primary identity provider instead of NetBird's embedded IdP. This option gives you full control over authentication and user management, is recommended for experienced Google Workspace administrators as it also requires additional setup and ongoing maintenance.
+
+Using Google directly as NetBird's identity provider, instead of the embedded IdP, is not recommended. It relies on the Management service sending the client secret to every NetBird client, which is deprecated and will be removed in a future release, and it does not support the device authorization flow used by headless Linux and FreeBSD peers. Use the [Management Setup (Recommended)](#management-setup-recommended) above instead.
-For most deployments, the [embedded IdP](/selfhosted/identity-providers/local) is the simpler choice — it's built into NetBird, fully integrated, and requires minimal configuration to get started. For this implementation, go back up to the [Management Setup (Recommended)](#management-setup-recommended) section above.
+If you already run this standalone setup, migrate to the embedded IdP with the [External IdP to Embedded IdP migration guide](/selfhosted/migration/external-to-embedded-idp). Existing users keep signing in with their Google accounts.
+
-For detailed instructions on the standalone setup, see the [Google Workspace SSO with NetBird Self-Hosted (Legacy)](/selfhosted/identity-providers/managed/advanced/google-workspace) documentation.
+For reference, the standalone setup instructions are kept in the [Google Workspace SSO with NetBird Self-Hosted (Legacy)](/selfhosted/identity-providers/managed/advanced/google-workspace) documentation.
If you prefer to have full control over authentication, consider self-hosted alternatives like [PocketID](/selfhosted/identity-providers/pocketid).
diff --git a/src/pages/selfhosted/maintenance/activity-mysql-store.mdx b/src/pages/selfhosted/maintenance/activity-mysql-store.mdx
deleted file mode 100644
index dd686f15..00000000
--- a/src/pages/selfhosted/maintenance/activity-mysql-store.mdx
+++ /dev/null
@@ -1,15 +0,0 @@
-# Activity Events MySQL store
-
-## Using MySQL for fresh installations
-
-To enable MySQL add to your management service the following environmental variable:
-```bash
-NB_ACTIVITY_EVENT_STORE_ENGINE=mysql
-NB_ACTIVITY_EVENT_MYSQL_DSN="mysql://:@:/"
-```
-
-You can switch back to sqlite storage by setting the `NB_ACTIVITY_EVENT_STORE_ENGINE` variable to `sqlite`.
-
- Switching between storage options requires migration steps to prevent data loss.
-
-
diff --git a/src/pages/selfhosted/maintenance/configuration-files.mdx b/src/pages/selfhosted/maintenance/configuration-files.mdx
index e3c66a7b..0a9cd275 100644
--- a/src/pages/selfhosted/maintenance/configuration-files.mdx
+++ b/src/pages/selfhosted/maintenance/configuration-files.mdx
@@ -333,10 +333,10 @@ Configures the database backend for storing all NetBird management data includin
- Database engine. Options: `sqlite`, `postgres`, `mysql`. Default: `sqlite`.
+ Database engine. Options: `sqlite`, `postgres`, `mysql`. Default: `sqlite`. `mysql` is deprecated, see [Migrate from MySQL to PostgreSQL](/selfhosted/migration/mysql-to-postgresql).
- Connection string for postgres or mysql engines. For postgres: `host=localhost user=netbird password=secret dbname=netbird port=5432`. Alternatively, use the `NETBIRD_STORE_ENGINE_POSTGRES_DSN` or `NETBIRD_STORE_ENGINE_MYSQL_DSN` environment variables.
+ Connection string for the postgres engine, or the deprecated mysql engine. For postgres: `host=localhost user=netbird password=secret dbname=netbird port=5432`. Alternatively, use the `NETBIRD_STORE_ENGINE_POSTGRES_DSN` environment variable (`NETBIRD_STORE_ENGINE_MYSQL_DSN` for mysql).
base64 string of a 32-byte (256-bit) encryption key for sensitive data at rest. Used to encrypt setup keys, API tokens, and other secrets stored in the database. Auto-generated by the setup script.
@@ -358,9 +358,9 @@ Configures the database backend for storing all NetBird management data includin
|--------|---------|-------|
| SQLite (default) | `/var/lib/netbird/` volume | File-based database stored in the `netbird_data` Docker volume. Zero configuration required, but does not support concurrent writes or running multiple management instances. Best for testing or small deployments. |
| PostgreSQL | External database server | Recommended for production deployments. Supports concurrent access, enabling multiple management instances for high availability. |
-| MySQL | External database server | Alternative to PostgreSQL for organizations that have standardized on MySQL/MariaDB. Provides similar benefits including concurrent access. |
+| MySQL | External database server | Deprecated and will be removed in an upcoming release. See [Migrate from MySQL to PostgreSQL](/selfhosted/migration/mysql-to-postgresql). |
-For PostgreSQL or MySQL, set the connection string via the `server.store.dsn` field in `config.yaml` or environment variables on the `netbird-server` container. See [Using an External Database](#using-an-external-database) below.
+For PostgreSQL, or the deprecated MySQL engine, set the connection string via the `server.store.dsn` field in `config.yaml` or environment variables on the `netbird-server` container. See [Using an External Database](#using-an-external-database) below.
See [Management Postgres Store](/selfhosted/maintenance/postgres-store) for detailed PostgreSQL setup.
@@ -499,8 +499,6 @@ Alternatively, you can use an environment variable instead of putting the DSN in
netbird-server:
environment:
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=db-server user=netbird password=secret dbname=netbird port=5432
- # Or for MySQL:
- # - NETBIRD_STORE_ENGINE_MYSQL_DSN=user:password@tcp(host:3306)/netbird
```
See [Management Postgres Store](/selfhosted/maintenance/postgres-store) for detailed setup.
diff --git a/src/pages/selfhosted/maintenance/crowdsec-dashboard.mdx b/src/pages/selfhosted/maintenance/crowdsec-dashboard.mdx
index 8d1afb65..452e8660 100644
--- a/src/pages/selfhosted/maintenance/crowdsec-dashboard.mdx
+++ b/src/pages/selfhosted/maintenance/crowdsec-dashboard.mdx
@@ -11,6 +11,8 @@ The configuration reuses the CrowdSec LAPI container created by the NetBird quic
For the NetBird Proxy IP reputation integration, see [CrowdSec IP Reputation](/selfhosted/maintenance/crowdsec).
+For updates to Traefik, CrowdSec, or the bouncer plugin, see [Upgrading Traefik and CrowdSec](/selfhosted/maintenance/upgrade#upgrading-traefik-and-crowd-sec).
+
## Prerequisites
- A self-hosted NetBird deployment using the internal Traefik reverse proxy.
@@ -86,25 +88,22 @@ EOF
Start from the `docker-compose.yml` generated by the quickstart with proxy CrowdSec enabled. The file already includes a `crowdsec` service, and `proxy.env` already points NetBird Proxy to `http://crowdsec:8080`.
-Under `services.traefik.command`, add the Traefik CrowdSec bouncer plugin static arguments:
+Set the Traefik image to the validated version below and add the header handling and bouncer plugin arguments to the existing `services.traefik.command` list:
```yaml
services:
traefik:
+ image: traefik:v3.7.14
command:
+ - '--entrypoints.web.http.aliasHeadersStrategy=delete'
+ - '--entrypoints.websecure.http.aliasHeadersStrategy=delete'
- '--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin'
- - '--experimental.plugins.bouncer.version=v1.6.0'
+ - '--experimental.plugins.bouncer.version=v1.7.1'
```
-The generated Compose file already sets `--providers.docker.network` in the Traefik command block, normally to `netbird`, matching the network key in the Compose file. Leave it as generated and do not add a second copy of the flag.
+The header settings remove request headers with names containing characters other than letters, digits, and hyphens. If you use custom headers or already set `underscoreHeadersStrategy`, review [Traefik header handling](/selfhosted/maintenance/upgrade#traefik-header-handling) before applying them.
-Only change it if Traefik logs warnings about a missing Docker network. Compose prefixes the network name with the project name, which defaults to the install directory name, so the actual name varies by deployment. Check it before editing:
-
-```bash
-docker network ls | grep netbird
-```
-
-An install in `/root` produces `root_netbird`; one in `/opt/netbird` produces `netbird_netbird`.
+Verify that the existing `--providers.docker.network` value matches the actual Docker network name, which can include a Compose project prefix. Follow [Traefik Docker network](/selfhosted/maintenance/upgrade#traefik-docker-network) to check it and replace a mismatched value. Update the existing flag rather than adding a second copy.
Under `services.traefik.depends_on`, make Traefik wait until CrowdSec is healthy:
@@ -164,7 +163,7 @@ Replace `netbird.example.com` with your NetBird domain. Traefik labels do not re
middleware to the `proxy-passthrough` TCP router.
-Merge these fields into `services.crowdsec`. This adds the AppSec collections, registers the dashboard bouncer key, and updates the health check so CrowdSec reports healthy only after both LAPI and AppSec are available. Keep the generated fields that already exist in your Compose file, such as `image`, `container_name`, `restart`, `networks`, `labels`, `logging`, and `volumes`.
+Merge these fields into `services.crowdsec`. This sets the validated image version, adds the AppSec collections, registers the dashboard bouncer key, and updates the health check so CrowdSec reports healthy only after both LAPI and AppSec are available. Keep the other generated fields, including `container_name`, `restart`, `networks`, `labels`, `logging`, and `volumes`.
The installer-created health check only verifies the CrowdSec Local API:
@@ -189,7 +188,7 @@ Dashboard protection also needs the AppSec listener on port `7422`, so replace t
```yaml
services:
crowdsec:
- ...
+ image: crowdsecurity/crowdsec:v1.8.1
environment:
COLLECTIONS: "crowdsecurity/linux crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
BOUNCER_KEY_netbird_dashboard: "${CROWDSEC_DASHBOARD_BOUNCER_KEY}"
@@ -241,6 +240,12 @@ A known malicious probe should return `403`:
curl -ks -A 'Mozilla/5.0 NetBirdDashboardCheck' -o /dev/null -w '%{http_code}\n' "https://$NETBIRD_DOMAIN/.env"
```
+
+ Use only the `/.env` and `/api/.env` probes when testing from your admin IP. Triggering two different AppSec rules within about a minute can ban that IP for 4 hours, blocking the dashboard, `/api`, and `/oauth2`. To lift a ban, [recover access over SSH](#recovering-from-a-dashboard-lockout).
+
+ The default `crowdsecurity/whitelists` parser exempts loopback and private IPv4 ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) from this ban. Requests from those addresses are still blocked individually with `403`, so testing from a LAN client does not show whether a public IP would be banned.
+
+
Check AppSec metrics and bouncer registration:
```bash
@@ -346,7 +351,7 @@ First identify which of the two mechanisms is blocking you, because they are han
docker compose exec crowdsec cscli decisions list
```
-If your address is listed, an **IP decision** is blocking you. If it is not, the block is an **AppSec** match on the request itself: AppSec inspects requests and does not create decisions, so `cscli decisions delete` has no effect in that case. Confirm with `cscli metrics show appsec` and `cscli alerts list`, where AppSec matches appear with `kind` set to `waf`.
+If your address is listed, an **IP decision** is blocking you. This includes bans created by the `crowdsecurity/appsec-vpatch` scenario after repeated AppSec matches, which appear with that reason. If it is not, the block is an **AppSec** match on the request itself: an individual AppSec match does not create a decision, so `cscli decisions delete` has no effect in that case. Confirm with `cscli metrics show appsec` and `cscli alerts list`, where AppSec matches appear with `kind` set to `waf`.
Allowlisting the address resolves both cases. `cscli allowlists add` fails with `allowlist '' not found` if the list does not already exist, so create it first if you have not set one up:
@@ -399,13 +404,7 @@ If CrowdSec is stopped or unreachable while `crowdsecAppsecUnreachableBlock=true
Separately, a reverse proxy service in `enforce` mode denies all connections while its bouncer has not completed its initial sync, for example when the proxy starts with the LAPI unavailable. See [Enforcement modes](/selfhosted/maintenance/crowdsec#enforcement-modes).
-If Traefik logs warnings about a missing Docker network, check the actual network name:
-
-```bash
-docker network ls | grep netbird
-```
-
-Then set `--providers.docker.network` to the actual network name, commonly `netbird_netbird`.
+If Traefik logs warnings about a missing Docker network, follow [Traefik Docker network](/selfhosted/maintenance/upgrade#traefik-docker-network) to correct the configured name.
If AppSec metrics do not increment, confirm that the middleware is attached to the dashboard router and that Traefik can reach `crowdsec:8080` and `crowdsec:7422`.
diff --git a/src/pages/selfhosted/maintenance/crowdsec.mdx b/src/pages/selfhosted/maintenance/crowdsec.mdx
index cf6d1f08..a24ff544 100644
--- a/src/pages/selfhosted/maintenance/crowdsec.mdx
+++ b/src/pages/selfhosted/maintenance/crowdsec.mdx
@@ -10,6 +10,8 @@ export const description = 'Enable CrowdSec IP reputation blocking for self-host
This page covers how the integration works and how to operate it. For full setup instructions, see [Step 7: Enable CrowdSec IP reputation](/selfhosted/migration/enable-reverse-proxy#step-7-optional-enable-crowdsec-ip-reputation) in the Enable Reverse Proxy guide.
+For version selection and updates to an existing deployment, see [Upgrading Traefik and CrowdSec](/selfhosted/maintenance/upgrade#upgrading-traefik-and-crowd-sec).
+
If you're running the [quickstart script](/selfhosted/selfhosted-quickstart) for a fresh installation, it offers to enable CrowdSec automatically when you choose the built-in Traefik option and enable the proxy.
@@ -173,6 +175,6 @@ docker compose exec crowdsec cscli alerts list
docker compose exec crowdsec cscli decisions delete --ip
```
-`cscli alerts list` includes a `kind` column that identifies which component generated the alert: `crowdsec` for local log detection, `waf` for an AppSec match, and `cscli` for a manually added decision. AppSec matches block the individual request but do not create an IP decision, so they never appear in `cscli decisions list`; use `cscli metrics show appsec` to inspect those instead.
+`cscli alerts list` includes a `kind` column that identifies which component generated the alert: `crowdsec` for local log detection, `waf` for an AppSec match, and `cscli` for a manually added decision. An individual AppSec match blocks only that request and does not appear in `cscli decisions list`; use `cscli metrics show appsec` to inspect those instead. If you installed the `crowdsecurity/appsec-virtual-patching` collection for [dashboard protection](/selfhosted/maintenance/crowdsec-dashboard), matches against two different AppSec rules from the same IP within about a minute trigger the `crowdsecurity/appsec-vpatch` scenario, which creates a regular 4-hour IP ban that does appear in `cscli decisions list`. Loopback and private IPv4 addresses are exempt from this ban through the default `crowdsecurity/whitelists` parser, although their individual matching requests are still blocked.
For dashboard lockouts specifically, see [Recovering from a dashboard lockout](/selfhosted/maintenance/crowdsec-dashboard#recovering-from-a-dashboard-lockout).
diff --git a/src/pages/selfhosted/maintenance/scaling/high-availability.mdx b/src/pages/selfhosted/maintenance/scaling/high-availability.mdx
index b2207e0e..d43b6274 100644
--- a/src/pages/selfhosted/maintenance/scaling/high-availability.mdx
+++ b/src/pages/selfhosted/maintenance/scaling/high-availability.mdx
@@ -4,7 +4,7 @@ import {Note, Warning} from "@/components/mdx";
A single NetBird server works well for many deployments, but production environments may need to remain available when an individual host or service instance fails. A highly available deployment removes those single points of failure by spreading services across separate failure domains and routing traffic to healthy instances.
-NetBird Enterprise supports **active-active high availability (HA)** for Management and Signal. This guide shows how to move an existing self-hosted deployment to that mode: Management, Signal, and Relay run as independent, load-balanced pools, while PostgreSQL, Redis, and NATS provide the shared state that lets a healthy instance continue serving traffic when another instance fails.
+NetBird Enterprise supports **active-active high availability (HA)** for Management and Signal. This guide shows how to move an existing self-hosted deployment to that mode: Management, Signal, and Relay run as independent pools: Management and Signal behind load balancers, and Relay with an address of its own for every instance. PostgreSQL, Redis, and NATS provide the shared state that lets a healthy Management or Signal instance continue serving traffic when another instance fails.
Use this guide when you need to tolerate the loss of a Management, Signal, Relay, or NATS node and perform rolling upgrades with minimal disruption. It assumes an existing Enterprise deployment that already uses PostgreSQL. For a distributed deployment without active-active Management and Signal, see [Splitting Your Self-Hosted Deployment](/selfhosted/maintenance/scaling/scaling-your-self-hosted-deployment).
@@ -22,7 +22,7 @@ This is an infrastructure migration, not an in-place switch. Build and validate
| You have a recent, restorable PostgreSQL backup. | Database migration or endpoint changes should always have a rollback path. |
| You have recorded the current `server.store.encryptionKey` value. | Every Management replica must use the exact same key to read existing encrypted data. |
| You can deploy instances in separate failure domains. | Two containers on one host do not provide high availability. |
-| You have stable DNS names and load balancers for Management, Signal, and Relay. | Peers and the dashboard must keep using stable URLs while backends change. |
+| You have stable DNS names and load balancers for Management and Signal, and a DNS name for every Relay instance. | Peers and the dashboard must keep using stable URLs while backends change. Relay instances are reached by their own names, not through a load balancer. |
| PostgreSQL and Redis can each expose one highly available, read-write endpoint. | Management replicas need a shared database and cache, not per-instance stores. |
| You can test the change in staging first. | Failure testing is part of validating an HA deployment. |
@@ -32,20 +32,20 @@ Build the HA deployment in this sequence:
1. Make PostgreSQL and Redis highly available.
2. Deploy and validate the three-node NATS cluster.
-3. Create the Management, Signal, and Relay load-balancer frontends and DNS records.
+3. Create the Management and Signal load-balancer frontends, and the DNS records for every service, including one per Relay instance.
4. Deploy the Relay and Signal pools.
5. Configure and deploy the Management pool.
6. Validate normal operation and then run the failure tests.
### Scope and endpoint choices
-This guide uses three public service URLs:
+This guide uses these public service URLs:
| Service | Example URL | Used by |
|---|---|---|
-| Management | `https://app.example.com` | Dashboard, Management API, Management gRPC, and OAuth |
+| Management | `https://netbird.example.com` | Dashboard, Management API, Management gRPC, and OAuth |
| Signal | `https://signal.example.com` | NetBird peers for signaling |
-| Relay | `rels://relay.example.com:443` | NetBird peers that need relay connectivity |
+| Relay | One per instance, e.g. `rels://us-1.relay.example.com:443` and `rels://eu-1.relay.example.com:443`. With the geo-DNS option, also a shared `rels://relay.example.com:443`. | NetBird peers that need relay connectivity |
The Management URL can remain the URL used by your existing deployment. If you change it, update the dashboard environment to use the new Management API and gRPC endpoints; see [Dashboard environment configuration](/selfhosted/maintenance/configuration-files#dashboardenv). The dashboard itself is stateless and can be served behind the Management load balancer or separately, as long as browsers can reach the configured Management URL.
@@ -55,7 +55,9 @@ A highly available deployment has three service pools. They have different conne
- **Management pool:** Enterprise Management replicas serve the dashboard API, Management gRPC, and OAuth2 endpoints. Fully stateless: any replica can serve any request because all durable state lives in PostgreSQL and all short-lived state lives in Redis. Replicas scale horizontally to absorb dashboard traffic, peer sync, and OAuth flows.
- **Signal pool:** Enterprise Signal replicas serve the Signal gRPC API. Peers connect to one Signal instance through the load balancer. The NATS cluster reconciles cross-instance peer signaling, so any Signal instance can deliver a message to any peer regardless of its connected instance. This makes Signal active-active in the Enterprise build. See [How active-active Signal works](#how-active-active-signal-works).
-- **Relay pool:** Relay instances serve WebSocket relay traffic for peers that cannot reach each other directly. Each relay session stays on one Relay instance for its lifetime through the load balancer's natural connection affinity. When an instance fails, peers reconnect through the load balancer to a healthy instance. Relay traffic flows directly between peers and the Relay pool.
+- **Relay pool:** Relay instances carry traffic for peers that cannot reach each other directly. Unlike Management and Signal, Relay does not go behind a load balancer. Relay instances share no state, so two peers can be relayed to each other only when each can reach the other's instance by that instance's own address. Every instance therefore has its own URL, and a peer that loses its relay moves to another instance by itself. See [Step 5](#step-5-deploy-the-relay-pool).
+
+If you use traffic events, two more services run alongside the Management pool: a flow receiver and a flow enricher. See [Deploy the flow receiver and enricher](#deploy-the-flow-receiver-and-enricher).
The pools depend on the following shared infrastructure:
@@ -63,15 +65,14 @@ The pools depend on the following shared infrastructure:
- **PostgreSQL HA endpoint:** Stores durable Management data, including accounts, peers, policies, OAuth state, and integrations. It is operator-managed. NetBird treats it as an opaque connection string and expects failover to happen transparently behind the DSN.
- **Redis HA endpoint:** Stores ephemeral cache data, including OAuth PKCE verifiers, peer cache data, and dynamic configuration. It is operator-managed. Losing Redis interrupts in-flight OAuth flows but does not break running peer connections.
-Each pool uses one stable URL backed by its load-balanced instances. Adding or removing instances is transparent to peers because the URL does not change.
+The Management and Signal pools each use one stable URL backed by load-balanced instances, so adding or removing one of their instances is transparent to peers. The Relay pool is reached by each instance's own address instead.
```mermaid
flowchart TB
Peers[NetBird Peers]
- LBM[Load Balancer
app.example.com]
+ LBM[Load Balancer
netbird.example.com]
LBS[Load Balancer
signal.example.com]
- LBR[Load Balancer
relay.example.com]
subgraph Mgmt[Management pool: Enterprise]
M1[Management 1]
@@ -84,8 +85,8 @@ flowchart TB
end
subgraph Rel[Relay pool]
- R1[Relay 1]
- R2[Relay 2]
+ R1[Relay us-1
us-1.relay.example.com]
+ R2[Relay eu-1
eu-1.relay.example.com]
end
subgraph NATSC[NATS cluster: 3 nodes with JetStream]
@@ -99,11 +100,10 @@ flowchart TB
Peers --> LBM
Peers --> LBS
- Peers -.->|relay traffic| LBR
+ Peers -.->|relay traffic, direct to each instance| R1 & R2
LBM --> M1 & M2
LBS --> S1 & S2
- LBR --> R1 & R2
M1 & M2 --> PG
M1 & M2 --> RD
@@ -111,7 +111,7 @@ flowchart TB
S1 & S2 --> NATSC
```
-The three load balancers can be independent load balancers, frontends on one shared load balancer, or managed load-balancer resources. Choose the model that fits your infrastructure. Each pool must be reached through one stable URL with at least two backend instances and a load balancer that can fail over within seconds.
+The two load balancers can be independent load balancers, frontends on one shared load balancer, or managed load-balancer resources. Choose the model that fits your infrastructure. Management and Signal must each be reached through one stable URL with at least two backend instances and a load balancer that can fail over within seconds.
### How active-active Signal works
@@ -124,9 +124,9 @@ In single-node mode, the Signal service keeps peer connection state in memory an
- At least **2 enterprise Signal instances**, on separate failure domains.
- At least **2 Relay instances**, on separate failure domains.
- At least **3 NATS instances** for the coordination cluster, on separate failure domains. NATS can colocate with NetBird hosts, but the 3 NATS instances must be on different failure domains.
-- A **load balancer** for each pool: Management, Signal, and Relay. These can be three independent load balancers, three frontends on one shared load balancer, or three managed load-balancer resources, as long as each pool is reachable through a single stable URL. Management and Signal require HTTP/2 + gRPC support. Relay requires WebSocket support.
-- Three public **FQDNs**, one per pool. For example: `app.example.com`, `signal.example.com`, and `relay.example.com`. Each must resolve to its corresponding load balancer.
-- Permissions to deploy services, mount configuration and secrets, expose network ports, and register instances with the load balancers in your environment.
+- A **load balancer** for the Management and Signal pools. These can be two independent load balancers, two frontends on one shared load balancer, or two managed load-balancer resources, as long as each pool is reachable through a single stable URL. Both require HTTP/2 + gRPC support. The Relay pool does not use a load balancer.
+- Public **FQDNs** for Management and Signal, for example `netbird.example.com` and `signal.example.com`, each resolving to its load balancer. One more per Relay instance, for example `us-1.relay.example.com` and `eu-1.relay.example.com`, each resolving to that instance. With the geo-DNS option, also a shared `relay.example.com`.
+- Permissions to deploy services, mount configuration and secrets, expose network ports, manage DNS records, and register instances with the load balancers in your environment.
## Step 1: Make PostgreSQL highly available
@@ -280,6 +280,95 @@ NetBird uses NATS for coordination between Management and Signal instances. Like
point of failure.
+### Example: a self-hosted cluster with Docker Compose
+
+This example runs one NATS node per host, on three hosts: `nats-1.example.com`, `nats-2.example.com` and `nats-3.example.com`. A NATS node can share a host with a NetBird node or a Signal instance, but no host may run two NATS nodes.
+
+Generate two passwords, one for NetBird and Signal to connect with and one for the NATS nodes to connect to each other. Use hex, so they are safe inside URLs:
+
+```bash
+openssl rand -hex 24
+```
+
+On each host, create a directory with two files. The first, `nats-server.conf`, differs per host in `server_name` and `client_advertise`:
+
+```text
+server_name: nats-1
+port: 4222
+client_advertise: "nats-1.example.com:4222"
+http_port: 8222
+
+jetstream {
+ store_dir: /data
+}
+
+authorization {
+ user: netbird
+ password: ""
+}
+
+cluster {
+ name: netbird
+ port: 6222
+ authorization {
+ user: route
+ password: ""
+ }
+ routes: [
+ "nats-route://route:@nats-1.example.com:6222"
+ "nats-route://route:@nats-2.example.com:6222"
+ "nats-route://route:@nats-3.example.com:6222"
+ ]
+}
+```
+
+`client_advertise` is the address each node gives clients for reconnecting. Without it, a node in a container advertises its Docker-internal address, which NetBird and Signal cannot reach.
+
+The second file, `docker-compose.yml`, is the same on every host:
+
+```yaml
+services:
+ nats:
+ image: nats:2.14
+ container_name: nats
+ restart: unless-stopped
+ command: ["-c", "/etc/nats/nats-server.conf"]
+ ports:
+ - "4222:4222"
+ - "6222:6222"
+ - "127.0.0.1:8222:8222"
+ volumes:
+ - ./nats-server.conf:/etc/nats/nats-server.conf:ro
+ - nats-data:/data
+
+volumes:
+ nats-data:
+```
+
+The configuration holds both passwords, so keep it readable by root only, then start the node:
+
+```bash
+chmod 600 nats-server.conf
+docker compose up -d
+```
+
+Allow port `4222` from the Management and Signal hosts only, and port `6222` between the NATS hosts only. Set these rules on your network firewall or security groups: ports that Docker publishes bypass host firewalls such as UFW and firewalld. The monitoring port `8222` listens on the host itself only.
+
+Once all three nodes run, check each one:
+
+```bash
+curl -s http://127.0.0.1:8222/healthz
+curl -s http://127.0.0.1:8222/jsz | grep -E '"cluster_size"|"leader"'
+```
+
+Each node answers `{"status":"ok"}`, reports `"cluster_size": 3` and names the same leader. Until all three have started, the nodes log `Error trying to connect to route` for the ones that are not up yet.
+
+NetBird and Signal connect as the `netbird` user, so the NATS URLs you give them carry the client password: `nats://netbird:@nats-1.example.com:4222`. Both write these URLs to their logs at startup, password included. Keep those logs as private as the configuration.
+
+This example does not configure TLS, so the passwords and all NATS traffic cross the network unencrypted. Keep NATS on a private network, or add TLS as described in the [NATS documentation](https://docs.nats.io/running-a-nats-service/configuration/securing_nats/tls).
+
+The `nats` CLI commands below also need the credentials: add `--user netbird --password ""`. The `server report` command needs a system account, which this example does not create; use the `curl` checks above instead.
+
### Traffic-flow stream
NetBird expects the `traffic-events` JetStream stream to be available for traffic-flow events. Configure it with:
@@ -308,6 +397,8 @@ nats --server nats://nats-1.example.com:4222 \
--defaults
```
+The flow receiver publishes to `netbird.flow.events` by default, a subject this stream does not match. Step 7 sets it to one under `traffic-events.`; see [Deploy the flow receiver and enricher](#deploy-the-flow-receiver-and-enricher).
+
If your NATS platform manages streams declaratively, apply the same settings through that platform instead. If the stream already exists with one replica, update it to three replicas:
```bash
@@ -340,118 +431,162 @@ Signal instances reference the same list via the `NATS_ENDPOINTS` environment va
**Why an explicit list and not a single DNS round-robin URL?** A single hostname with multiple A/AAAA records (e.g. `nats://nats.example.com:4222`) works because the underlying client resolves and reconnects, but recovery time depends on DNS TTL and cache. An explicit comma-separated list of node addresses gives immediate visibility of every node and the fastest possible failover when one becomes unreachable. **Prefer the explicit list.**
-## Step 4: Configure the three load balancers
+## Step 4: Configure the load balancers
Use the load balancer your organization already operates. NetBird works with any Layer 7 load balancer that supports HTTP/2 and gRPC, including HAProxy, NGINX, AWS Application Load Balancer, Google Cloud Load Balancing, Azure Application Gateway, and Envoy.
-Plan one load-balancer frontend for each service pool: Management, Signal, and Relay. These can be separate load balancers or three frontends on a shared load balancer. Each frontend needs its own DNS name and backend pool. Configure the frontends and empty backend pools now, then add instances as you deploy the Relay, Signal, and Management services in Steps 5 through 7.
+Plan one load-balancer frontend for each of the Management and Signal pools. These can be separate load balancers or two frontends on a shared load balancer. Each frontend needs its own DNS name and backend pool. Configure the frontends and empty backend pools now, then add instances as you deploy the Signal and Management services in Steps 6 and 7. The Relay pool does not go behind a load balancer; see Step 5.
| Pool | Public FQDN | Backend port | Frontend protocol | Health check |
|---|---|---|---|---|
-| Relay | e.g. `relay.example.com` | 443 | HTTPS with WebSocket upgrade | TCP/443 |
| Signal | e.g. `signal.example.com` | 443 | HTTPS, HTTP/2, gRPC | TCP/443 (or gRPC health if supported) |
-| Management | e.g. `app.example.com` | 443 | HTTPS, HTTP/2, gRPC | `GET /api/health` → HTTP 200 |
+| Management | e.g. `netbird.example.com` | 443 | HTTPS, HTTP/2, gRPC | `GET /oauth2/.well-known/openid-configuration` → HTTP 200 |
Common requirements for every pool:
| Requirement | Notes |
|---|---|
| **TLS termination** | **Prefer terminating TLS at the load balancer.** Set `server.tls` to empty on the Management replicas. If your environment requires TLS pass-through, configure TLS on every backend instead. |
-| **HTTP/2 + gRPC support end-to-end** | Required for Management and Signal pools. The Relay pool uses WebSockets over HTTPS. Make sure your load balancer supports WebSocket upgrades and long-lived connections. |
+| **HTTP/2 + gRPC support end-to-end** | Required for Management and Signal pools. Make sure your load balancer supports long-lived connections. |
| **Connection-level affinity** | This is the default for Layer 4 load balancers and Layer 7 load balancers that use HTTP/2 streaming. A long-lived connection stays on one backend for its lifetime. **No application-level sticky sessions are required.** NATS reconciles cross-instance Signal state, while PostgreSQL and Redis hold Management state. |
| **Active health checks** | Mark backends out of the pool on failure within seconds, not minutes. |
-| **Connection draining on rolling upgrade** | When you remove a backend, the LB should let in-flight gRPC streams and WebSocket connections finish before tearing them down. |
-| **Generous idle timeout** | Management gRPC streams and Relay WebSocket connections can be long-lived. Set the idle timeout above your peer-sync interval. Ten to 30 minutes is a comfortable range for both. |
-
-For the Relay frontend, configure the load balancer to strip any client-supplied `X-Real-Ip` and `X-Real-Port`, then overwrite both headers from the validated client connection. Each Relay instance must trust only the load balancer addresses that connect directly to it, using `NB_TRUSTED_PROXIES`. Without that trust, forwarded client addresses are intentionally ignored.
+| **Connection draining on rolling upgrade** | When you remove a backend, the LB should let in-flight gRPC streams finish before tearing them down. |
+| **Generous idle timeout** | Management and Signal gRPC streams can be long-lived. Set the idle timeout above your peer-sync interval. Ten to 30 minutes is a comfortable range for both. |
For the full set of paths and protocols NetBird exposes to each load balancer, see [Configuration Files Reference](/selfhosted/maintenance/configuration-files).
+The Management check confirms that a replica is up and serving. It keeps returning 200 while PostgreSQL or Redis is unreachable, so monitor those separately.
+
If you're using a managed cloud load balancer, configure the equivalent of each row above using your provider's UI or infrastructure as code. If you're using a self-hosted reverse proxy, create one backend pool per service, attach health checks, and enable HTTP/2 or WebSocket support as appropriate for each pool.
## Step 5: Deploy the Relay pool
-Deploy at least two Relay instances on separate failure domains. Configure every instance with the same `NB_AUTH_SECRET` and `NB_EXPOSED_ADDRESS`, which is the Relay load balancer URL. The Relay load balancer distributes new WebSocket connections across instances. Each connection stays on one instance for its lifetime through the load balancer's natural connection affinity.
+Deploy at least two Relay instances on separate failure domains. The Relay pool uses the **upstream Relay image** (`netbirdio/relay`). There is no Enterprise-specific Relay image. Relay does not validate a license. It authenticates incoming connections against the shared `NB_AUTH_SECRET`.
-The Relay pool uses the **upstream Relay image** (`netbirdio/relay`). There is no Enterprise-specific Relay image. Relay does not validate a license. It authenticates incoming WebSocket connections against the shared `NB_AUTH_SECRET`.
+Relay instances share no state with each other, which is why this pool works differently from Management and Signal. The instance a peer connects to becomes its home relay, and announces its own address to that peer. When two peers are on different instances, each reaches the other's instance directly by that announced address. So **every instance needs an `NB_EXPOSED_ADDRESS` of its own**, with a DNS name and a TLS certificate that peers can reach.
-Run this on each Relay host. For example, use `relay-host-1` and `relay-host-2` as internal hostnames because peers reach the instances only through the load balancer URL:
+
+Do not put Relay instances behind a load balancer that gives them one shared address. Two peers whose connections land on different instances then cannot be relayed to each other: each waits for the other to come online on its own instance, and the connection never forms. Every peer still reports the relay as `Available`, so the only symptoms are peers that stay `Connecting`, with `peer not available: ..., context deadline exceeded` in the client log.
+
+
+Choose how peers find an instance:
+
+| Option | `server.relays.addresses` | Which instance a peer uses | Choose it when |
+|---|---|---|---|
+| **1. List every instance** | Every instance's URL | The first to answer: the client connects to all of them in parallel and keeps the fastest, normally the nearest | By default. There is nothing extra to run. |
+| **2. Geo-DNS** | One shared name that your DNS provider resolves to a nearby instance | The one DNS returns for the peer's location | You run many instances across regions and do not want every peer racing all of them. |
+
+In both options, a peer that loses its relay moves to another instance by itself, and peers on different instances still reach each other.
+
+### Option 1: list every instance
+
+Give each instance its own DNS name and let it obtain its own Let's Encrypt certificate for that name. On `us-1`, create `/etc/netbird/relay/relay.env`:
+
+```bash
+NB_LOG_LEVEL=info
+NB_LISTEN_ADDRESS=:443
+# This instance's own address. Different on every instance.
+NB_EXPOSED_ADDRESS=rels://us-1.relay.example.com:443
+NB_AUTH_SECRET=
+NB_LETSENCRYPT_DOMAINS=us-1.relay.example.com
+NB_LETSENCRYPT_EMAIL=admin@example.com
+NB_LETSENCRYPT_DATA_DIR=/data/letsencrypt
+NB_ENABLE_STUN=true
+NB_STUN_PORTS=3478
+```
+
+The file holds the shared secret, so make it readable by root only:
+
+```bash
+chmod 600 /etc/netbird/relay/relay.env
+```
+
+Then create `/etc/netbird/relay/docker-compose.yml`:
```yaml
-# /etc/netbird/relay/docker-compose.yml on each Relay host
services:
relay:
image: netbirdio/relay:latest
container_name: netbird-relay
restart: unless-stopped
ports:
- - "443:443" # WebSocket relay traffic (registered with the Relay LB)
- - "3478:3478/udp" # STUN is direct and not load-balanced. See below.
- environment:
- - NB_LOG_LEVEL=info
- - NB_LISTEN_ADDRESS=:443
- - NB_EXPOSED_ADDRESS=rels://relay.example.com:443
- - NB_AUTH_SECRET=
- - NB_TRUSTED_PROXIES=
- - NB_ENABLE_STUN=true
- - NB_STUN_PORTS=3478
+ - "443:443/tcp" # relay over WebSocket
+ - "443:443/udp" # relay over QUIC
+ - "3478:3478/udp" # STUN
+ env_file:
+ - relay.env
+ volumes:
+ - relay_data:/data
+
+volumes:
+ relay_data:
```
-| Env var | Required | Notes |
-|---|---|---|
-| `NB_EXPOSED_ADDRESS` | Yes | The Relay load balancer URL. **It must be identical on every instance.** This is the URL peers see, not the per-instance hostname. |
-| `NB_AUTH_SECRET` | Yes | Shared with `server.relays.secret` in every Management replica's `config.yaml`. Must be byte-identical on every Relay instance and on every Management replica. |
-| `NB_LISTEN_ADDRESS` | Yes | The address the relay binds inside the container. |
-| `NB_LOG_LEVEL` | No | `debug`, `info` (default), `warn`, `error`. |
-| `NB_TRUSTED_PROXIES` | When preserving client addresses behind the LB | Comma-separated immediate load-balancer IPs or CIDRs allowed to supply `X-Real-Ip` and `X-Real-Port`. Both headers are required. The load balancer must strip client-supplied values and overwrite them from the validated connection. Never use broad, uncontrolled ranges. |
-| `NB_ENABLE_STUN` | No | Enables the embedded STUN server on UDP. |
-| `NB_STUN_PORTS` | No | STUN port (default `3478`). |
+Repeat on every instance with its own name, for example `eu-1.relay.example.com`. Open 443/tcp, 443/udp and 3478/udp to the internet on each. No inbound port 80 is needed: the relay proves its domain to Let's Encrypt over 443.
-Start the relay on each host:
+### Option 2: geo-DNS
+
+Option 2 keeps everything in option 1 and adds one shared name, `relay.example.com`, that resolves to a nearby instance. A peer connects to the shared name, lands on an instance, and from then on is known by that instance's own address. Your DNS provider must support location-based or latency-based answers, such as Route 53 geolocation or latency routing.
+
+Keep `NB_EXPOSED_ADDRESS` unique on every instance, exactly as in option 1. The shared name goes only in `server.relays.addresses`, never in `NB_EXPOSED_ADDRESS`.
+
+Two things change:
+
+- **The certificate.** A peer's first connection uses the shared name, and its connections to another peer's instance use that instance's name, so every instance must present one certificate valid for both. For example, `relay.example.com` plus `*.relay.example.com`. What breaks depends on which name is missing:
+ - Without the shared name, no peer can connect at all: `x509: certificate is valid for us-1.relay.example.com, not relay.example.com`.
+ - Without the instance's own name, every peer connects and looks healthy, but peers on different instances cannot relay to each other: `x509: certificate is valid for relay.example.com, not us-1.relay.example.com`.
+
+ A wildcard needs DNS-based validation from your CA. The relay's built-in Let's Encrypt is not suitable: it proves the domain over port 443, and Let's Encrypt's check of the shared name reaches whichever instance DNS returns to Let's Encrypt. Every other instance fails with `acme/autocert: ... no viable challenge type found`. Supply the certificate yourself. Delete the three `NB_LETSENCRYPT_*` lines, set both `NB_TLS_CERT_FILE` and `NB_TLS_KEY_FILE`, and mount the files. Restart every instance after each renewal, because the relay reads the files only at startup.
+- **Health checks on the DNS record.** Peers of a failed instance reconnect through the shared name, so the record must stop returning that instance within seconds. Attach health checks and keep the TTL low, or peers are sent back to the failed instance until the record changes. Once it does, peers re-home by themselves on their next reconnection attempt, which can take a minute or more.
+
+### Start and verify each instance
```bash
-docker compose -f /etc/netbird/relay/docker-compose.yml up -d
+cd /etc/netbird/relay
+docker compose up -d
docker compose logs -f relay # verify startup
```
-### Register the Relay instances in the Relay LB
-
-Add each Relay host's IP (or backend reference, depending on your LB) to the Relay LB's backend pool on port `443`. Once all instances are registered and healthy, verify:
+Then check each instance by its own name from outside:
```bash
-curl -I https://relay.example.com/
+curl -v https://us-1.relay.example.com/
```
-You should get a `200 OK`. A `426 Upgrade Required` response is also healthy when the relay rejects non-WebSocket probes.
+A `404 page not found` response with `SSL certificate verify ok` is healthy. With option 2, run the same check against `relay.example.com` from a client, and confirm the certificate is valid for that name too.
### STUN handling
-STUN is served by the **Relay instances themselves**. Each relay container with `NB_ENABLE_STUN=true` runs an embedded STUN server on UDP/3478 alongside the WebSocket relay listener. You do **not** need a separate STUN/TURN service.
+STUN is served by the **Relay instances themselves**. Each relay container with `NB_ENABLE_STUN=true` runs an embedded STUN server on UDP/3478 alongside the relay listener. You do **not** need a separate STUN/TURN service.
-Tell peers where to find STUN through `server.stuns` in the Management configuration. UDP/3478 cannot pass through an HTTP or TCP load balancer, so peers reach each Relay instance's STUN endpoint **directly**. Configure one STUN URL and DNS A record for each Relay instance:
+Tell peers where to find STUN through `server.stuns` in the Management configuration, with one entry per Relay instance under that instance's own name.
-- Add an A record per relay host: `stun-1.example.com → relay-host-1`, `stun-2.example.com → relay-host-2`, etc.
-- List each in `server.stuns` (see the config fragment below). Peers iterate the list during NAT discovery.
+### How Management points at the Relay pool and STUN
-### How Management points at the Relay LB and STUN
-
-Every Management replica's `config.yaml` references the Relay pool through a **single load balancer URL** in `server.relays.addresses`. The load balancer distributes traffic across Relay instances, so this list always has one entry. Configure STUN separately under `server.stuns`:
+Every Management replica's `config.yaml` lists the Relay pool in `server.relays.addresses` and STUN in `server.stuns`. With option 1, list every instance:
```yaml
server:
- # External STUN: per-instance hostnames (option 1) or external STUN/TURN (option 2)
stuns:
- - uri: "stun:stun-1.example.com:3478"
+ - uri: "stun:us-1.relay.example.com:3478"
proto: "udp"
- - uri: "stun:stun-2.example.com:3478"
+ - uri: "stun:eu-1.relay.example.com:3478"
proto: "udp"
- # External Relay pool: one URL pointing at the Relay load balancer
+ relays:
+ addresses:
+ - "rels://us-1.relay.example.com:443"
+ - "rels://eu-1.relay.example.com:443"
+ secret: ""
+ credentialsTTL: "24h"
+```
+
+With option 2, `relays.addresses` holds only the shared name:
+
+```yaml
relays:
addresses:
- "rels://relay.example.com:443"
- secret: ""
- credentialsTTL: "24h"
```
The full Management `config.yaml` is assembled in [Step 7](#step-7-configure-and-deploy-the-management-pool).
@@ -492,6 +627,8 @@ services:
| `NATS_ENDPOINTS` | Yes | Comma-separated list of NATS cluster endpoints from Step 3. Identical on every Signal instance. |
| `SINGLE_NODE_MODE` | Yes | Must be `false`. When unset or set to `true`, Signal runs in single-node mode without NATS coordination. This mode is incompatible with HA. |
+When the NATS URLs carry a password, as in the [Step 3 example](#example-a-self-hosted-cluster-with-docker-compose), set `NATS_ENDPOINTS` in a file listed under `env_file`, readable by root only, instead of in `docker-compose.yml`.
+
Signal gRPC requires TLS. Either terminate TLS at the Signal load balancer and forward plain HTTP/2 (h2c) to backends on port 443, or pass TLS through to the backends. With TLS pass-through, mount certificates into each Signal container and adjust the `--port` or listen address as needed.
Start Signal on each host:
@@ -527,26 +664,41 @@ The full Management `config.yaml` is assembled in [Step 7](#step-7-configure-and
## Step 7: Configure and deploy the Management pool
-Now configure the Management replicas to point at everything you've set up: Postgres, Redis, NATS, the Relay LB URL, and the Signal LB URL. Distribute the same `config.yaml` to every enterprise Management replica.
+Now configure the Management replicas to point at everything you've set up: Postgres, Redis, NATS, the Relay instances, and the Signal LB URL. Every replica runs the Enterprise combined server image, `ghcr.io/netbirdio/netbird-server-cloud`, which the Enterprise installer also deploys. Distribute the same `config.yaml` to every replica.
-`server.relays.addresses` and `server.signalUri` each take **one URL**, which is the load balancer URL for that pool. The load balancer distributes traffic across the instances behind it, so NetBird does not need to know the individual Relay or Signal instance addresses.
+Keep the `auth:` block from your existing deployment's `config.yaml`. Without it the server exits at startup with `failed to create embedded IDP service: issuer is required`.
+
+`server.signalUri` takes **one URL**, the Signal load balancer's, which distributes traffic across the Signal instances. `server.relays.addresses` takes every Relay instance's URL with option 1, or only the geo-DNS name with option 2. See [Step 5](#step-5-deploy-the-relay-pool).
```yaml
server:
- exposedAddress: "https://app.example.com:443"
+ exposedAddress: "https://netbird.example.com:443"
dataDir: "/var/lib/netbird/"
- # External STUN: see Step 5 for the per-instance and external STUN options
+ # Embedded identity provider: keep the block from your existing config.yaml
+ auth:
+ issuer: "https://netbird.example.com/oauth2"
+ localAuthDisabled: false
+ signKeyRefreshEnabled: false
+ sessionCookieEncryptionKey: ""
+ dashboardRedirectURIs:
+ - "https://netbird.example.com/nb-auth"
+ - "https://netbird.example.com/nb-silent-auth"
+ cliRedirectURIs:
+ - "http://localhost:53000/"
+
+ # External STUN: one entry per Relay instance, see Step 5
stuns:
- - uri: "stun:stun-1.example.com:3478"
+ - uri: "stun:us-1.relay.example.com:3478"
proto: "udp"
- - uri: "stun:stun-2.example.com:3478"
+ - uri: "stun:eu-1.relay.example.com:3478"
proto: "udp"
- # External Relay pool: one URL pointing at the Relay load balancer
+ # Relay pool: every instance (option 1). With option 2, only the geo-DNS name.
relays:
addresses:
- - "rels://relay.example.com:443"
+ - "rels://us-1.relay.example.com:443"
+ - "rels://eu-1.relay.example.com:443"
secret: ""
credentialsTTL: "24h"
@@ -575,7 +727,7 @@ server:
trafficFlow:
enabled: true
- address: "https://app.example.com:443"
+ address: "https://netbird.example.com:443"
interval: "60s"
```
@@ -597,20 +749,82 @@ Bring up replicas one at a time and register each in the Management LB once heal
nats --server nats://nats-1.example.com:4222 server check connection # expect: OK
```
2. **Distribute `config.yaml`** to every Management replica host. Verify identical files (`sha256sum config.yaml`) on each.
-3. **Start replica 1**. Wait for `/api/health` to return 200 and check the logs for `Management server created` followed by `Starting CloudServer`.
-4. **Register replica 1** in the Management LB. Confirm the dashboard is reachable via `https://app.example.com/`.
+3. **Start replica 1**. Wait for `/oauth2/.well-known/openid-configuration` to return 200 and check the logs for `Management server created` followed by `Starting CloudServer`.
+4. **Register replica 1** in the Management LB. Confirm the dashboard is reachable via `https://netbird.example.com/`.
5. **Start replica 2**, verify health, register in the LB.
6. **Repeat for any additional replicas.**
+### Deploy the flow receiver and enricher
+
+The `trafficFlow` block above tells peers to send their traffic events to `https://netbird.example.com:443`. The Management server does not receive them. Two Enterprise services do:
+
+- The **flow receiver** (`ghcr.io/netbirdio/flow-receiver-cloud`) accepts the events from peers over gRPC and publishes them to the `traffic-events` stream in NATS.
+- The **flow enricher** (`ghcr.io/netbirdio/flow-enricher-cloud`) reads the stream and writes the events to PostgreSQL, where the dashboard reads them.
+
+Without them, peers keep connecting normally, but no traffic events ever appear and the client log repeats `flow receiver sent no headers`.
+
+Run one receiver and one enricher next to each Management replica. If one host fails, peers send their events through the receiver on another host, and the enrichers share the stream without storing an event twice.
+
+```yaml
+services:
+ receiver:
+ image: ghcr.io/netbirdio/flow-receiver-cloud:latest
+ restart: unless-stopped
+ ports:
+ - "8084:8084"
+ environment:
+ - NB_LICENSE_KEY=
+ - NB_FLOW_LISTEN_PORT=8084
+ - NB_FLOW_ADAPTER_TYPE=nats
+ - NB_FLOW_NATS_ENDPOINTS=nats://netbird:@nats-1.example.com:4222,nats://netbird:@nats-2.example.com:4222,nats://netbird:@nats-3.example.com:4222
+ - NB_FLOW_NATS_STREAM=traffic-events
+ - NB_FLOW_NATS_SUBJECT=traffic-events.flow
+ - NB_FLOW_AUTH_SECRET=
+
+ enricher:
+ image: ghcr.io/netbirdio/flow-enricher-cloud:latest
+ restart: unless-stopped
+ volumes:
+ - netbird_enricher:/var/lib/netbird
+ environment:
+ - NB_LICENSE_KEY=
+ - NB_DATADIR=/var/lib/netbird
+ - NB_MANAGEMENT_STORE_ENGINE=postgres
+ - NB_MANAGEMENT_POSTGRES_DSN=host=pg.example.com user=netbird password=*** dbname=netbird port=5432 sslmode=require
+ - NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=pg.example.com user=netbird password=*** dbname=netbird port=5432 sslmode=require
+ - NB_TRAFFIC_EVENT_STORE_ENGINE=postgres
+ - NB_TRAFFIC_EVENT_POSTGRES_DSN=host=pg.example.com user=netbird password=*** dbname=netbird port=5432 sslmode=require
+ - NB_MANAGEMENT_STORE_KEY=
+ - NB_FLOW_ADAPTER_TYPE=nats
+ - NB_FLOW_NATS_ENDPOINTS=nats://netbird:@nats-1.example.com:4222,nats://netbird:@nats-2.example.com:4222,nats://netbird:@nats-3.example.com:4222
+ - NB_FLOW_NATS_STREAM=traffic-events
+ - NB_METRICS_PORT=9091
+ - NB_PERSISTENCE_RETENTION_PERIOD=168h
+
+volumes:
+ netbird_enricher:
+```
+
+Two values must match what you configured earlier. Each one fails silently when it does not:
+
+| Setting | Must be | If it is not |
+|---|---|---|
+| `NB_FLOW_NATS_SUBJECT` | A subject under `traffic-events.`, the stream's subjects from [Step 3](#traffic-flow-stream) | The default, `netbird.flow.events`, matches no stream. Every publish fails and the receiver logs `failed to publish message to nats: nats: no response from stream`. |
+| `NB_FLOW_AUTH_SECRET` | The same value as `server.relays.secret` | Management signs the peers' flow tokens with the relay secret. The receiver rejects every peer with `invalid token validation: invalid signature`. |
+
+Then route the events to the receivers. On the Management load balancer, send the path prefix `/flow.FlowService/` on `netbird.example.com` to port 8084 on every receiver, over HTTP/2 like the other gRPC paths. Without that route, the events never reach a receiver.
+
+To verify, generate traffic between two peers in a group with traffic events enabled. `nats stream info traffic-events` shows the message count rising, and `GET /api/events/network-traffic` returns the events.
+
## Step 8: Verify HA end to end
Run each failure scenario and confirm the expected behavior. Do this in a staging environment first.
| Scenario | Expected behavior |
|---|---|
-| Stop one Management replica | The Management LB marks it unhealthy within seconds; API traffic continues on remaining replicas. Established peer connections (signal, relay) are unaffected because they flow through the Signal and Relay LBs, not the Management LB. |
+| Stop one Management replica | The Management LB marks it unhealthy within seconds; API traffic continues on remaining replicas. Established peer connections (signal, relay) are unaffected because they flow through the Signal LB and directly to the Relay instances, not through the Management LB. |
| Stop one Signal instance | The Signal LB marks it unhealthy; peers connected to that instance reconnect via the LB and land on a surviving Signal instance. Cross-peer signaling continues via NATS without interruption to peers that were already connected to a different instance. |
-| Stop one Relay instance | The Relay LB marks it unhealthy; new peer relay sessions go to surviving instances. Existing WebSocket sessions on the killed instance drop and peers re-establish via the LB. |
+| Stop one Relay instance | Peers homed on that instance move to another by themselves: with option 1 to the next instance that answers, with option 2 to the instance DNS returns once its health check drops the stopped one. Relayed connections through the stopped instance drop and re-establish. Peers homed on other instances are unaffected. |
| Stop one NATS node | Cluster retains quorum (2 of 3 healthy). Writes to the `traffic-events` stream continue. Cross-instance Signal routing continues. |
| Disconnect Redis | New OAuth flows fail until Redis returns; established peer connections continue working. Dynamic log/rate-limit changes pause. |
| Trigger PostgreSQL failover (managed service) | Brief outage during the failover; Management replicas reconnect to the new primary and resume. Existing peer connections survive the gap because they don't touch PostgreSQL on every message. |
@@ -621,7 +835,7 @@ If any scenario doesn't match the expected behavior, see [Troubleshooting](#trou
### Rolling upgrades
-The procedure is the same for any of the three pools: drain one instance, upgrade it, return it to the LB, repeat. Schema migrations on the Management pool run automatically on first startup of any replica; subsequent replicas detect the migrated schema and start without re-running it.
+For the Management and Signal pools, drain one instance, upgrade it, return it to the LB, and repeat. Schema migrations on the Management pool run automatically on first startup of any replica; subsequent replicas detect the migrated schema and start without re-running it.
1. Mark instance #1 as draining in its load balancer. Wait for active gRPC streams or WebSocket connections to drain (or hit your drain timeout).
2. Stop and re-pull the image on instance #1: `docker compose pull && docker compose up -d`.
@@ -629,13 +843,17 @@ The procedure is the same for any of the three pools: drain one instance, upgrad
4. Add instance #1 back to the LB pool.
5. Repeat for the remaining instances in the pool.
+The Relay pool has no load balancer to drain. Upgrade one instance at a time, and peers on it move to another instance by themselves. With option 2, take the instance out of the DNS record first.
+
Upgrade Relay, Signal, and Management pools in any order. The protocols between pools are stable across patch releases.
### Adding or removing instances
-To **add** an instance to a pool, provision the host, install the same image with the same configuration as existing instances, start the service, and add it to the load balancer pool once healthy. No additional coordination is required because the new instance reads the same shared state.
+To **add** a Management or Signal instance, provision the host, install the same image with the same configuration as existing instances, start the service, and add it to the load balancer pool once healthy. No additional coordination is required because the new instance reads the same shared state.
-To **remove** an instance: mark it as draining in the LB, wait for streams to drain, stop the service. The remaining instances continue serving traffic.
+A Relay instance is different: it needs its own name, certificate and `NB_EXPOSED_ADDRESS`. With option 1, add its URL to `server.relays.addresses` and its STUN entry to `server.stuns` on every Management replica, then restart the replicas one at a time. With option 2, add it to `server.stuns` the same way, and to the geo-DNS record once it is healthy.
+
+To **remove** a Management or Signal instance: mark it as draining in the LB, wait for streams to drain, stop the service. The remaining instances continue serving traffic. To remove a Relay instance, first take it out of `server.relays.addresses` (option 1) or the geo-DNS record (option 2), and out of `server.stuns`, then stop it.
### Rotating secrets
@@ -643,7 +861,7 @@ To **remove** an instance: mark it as draining in the LB, wait for streams to dr
|---|---|
| `POSTGRES_PASSWORD` / Postgres user password | Update the password in PostgreSQL, update the DSN in every Management replica's `config.yaml`, restart Management replicas one at a time. |
| Redis password | Update Redis, update `server.ha.redisAddr` on every Management replica, restart Management replicas one at a time. |
-| `NB_AUTH_SECRET` (relay auth) | Update every Relay instance simultaneously, then update `server.relays.secret` on every Management replica at the same time. Peer relay sessions can be rejected while the values differ. Restart Management and Relay together to minimise this window. |
+| `NB_AUTH_SECRET` (relay auth) | Update every Relay instance simultaneously, then update `server.relays.secret` on every Management replica at the same time. Peer relay sessions can be rejected while the values differ. Restart Management and Relay together to minimise this window. On each Relay instance, apply the new value with `docker compose up -d`: `docker compose restart` keeps the old one. |
| `server.store.encryptionKey` | **Do not rotate.** This key encrypts data at rest in PostgreSQL. Rotating it makes existing encrypted data unreadable. Plan a fresh deployment if you need to change it. |
## Troubleshooting
@@ -680,13 +898,19 @@ nats --server nats://nats-1.example.com:4222 server check jetstream
If a node is down, restart it and wait for it to rejoin the cluster. The `traffic-events` stream resumes writes as soon as quorum returns.
-### Relay rejects peer connections with `auth: invalid token`
+### Relay rejects peer connections: `invalid signature`
-`NB_AUTH_SECRET` on the Relay instances differs from `server.relays.secret` on the Management replicas. The two values must be byte-identical. Confirm on every Relay container and in every Management replica's `config.yaml`.
+`NB_AUTH_SECRET` on the Relay instances differs from `server.relays.secret` on the Management replicas. The relay logs `failed to handshake: validate ... invalid signature` for every rejected peer. The two values must be byte-identical. Confirm on every Relay container and in every Management replica's `config.yaml`.
-### Management replica refuses to start: `Redis is required for multi-instance mode`
+### Relayed connections never form between some peers: `peer not available`
-`server.ha.enabled: true` but `server.ha.redisAddr` is empty or unreachable. Set the address and confirm connectivity from the replica:
+Some pairs of peers stay `Connecting` while others relay normally, every peer reports the relay as `Available`, and the client log shows `peer not available: ..., context deadline exceeded`. The Relay instances share one `NB_EXPOSED_ADDRESS`, usually because they sit behind a load balancer, so peers on different instances cannot find each other. Give every instance its own address, as in [Step 5](#step-5-deploy-the-relay-pool).
+
+If the client log shows `x509: certificate is valid for relay.example.com, not us-1.relay.example.com` instead, the addresses are right but an instance's certificate lacks its own name. See option 2 in [Step 5](#step-5-deploy-the-relay-pool).
+
+### Management replica refuses to start: Redis
+
+`server.ha.enabled: true` needs a reachable `server.ha.redisAddr`. If it is empty, the replica exits with `server.ha.redisAddr is required when ha.enabled is true`. If Redis cannot be reached, it exits with `failed to create shared cache store: dial tcp ...: connect: connection refused`. Set the address and confirm connectivity from the replica:
```bash
redis-cli -u "" ping
diff --git a/src/pages/selfhosted/maintenance/scaling/scaling-your-self-hosted-deployment.mdx b/src/pages/selfhosted/maintenance/scaling/scaling-your-self-hosted-deployment.mdx
index aa1fc07a..a8b539cb 100644
--- a/src/pages/selfhosted/maintenance/scaling/scaling-your-self-hosted-deployment.mdx
+++ b/src/pages/selfhosted/maintenance/scaling/scaling-your-self-hosted-deployment.mdx
@@ -23,11 +23,12 @@ The default single-server deployment runs all services on one machine: **Traefik
and a **combined netbird-server** container that includes Management, Signal, and Relay + STUN as components. Traefik handles TLS termination on ports 80/443, while STUN listens on UDP port 3478. The Management server uses a **SQLite** database by default.
After splitting, the **main server** keeps Traefik, Dashboard, Management, and optionally Signal.
-The **relay servers** run independently on different machines, each handling relay (port 443) and STUN (port 3478) traffic. Peers receive relay addresses from the Management server and connect to them directly. Optionally, the SQLite database can be migrated to **PostgreSQL** on a dedicated server, and Signal can also be extracted to its own machine.
+The **relay servers** run independently on different machines, each handling relay (port 443, TCP for WebSocket and UDP for QUIC) and STUN (port 3478/udp) traffic. Peers receive relay addresses from the Management server and connect to them directly. Optionally, the SQLite database can be migrated to **PostgreSQL** on a dedicated server, and Signal can also be extracted to its own machine.
## Guides
- [Set Up External Relay Servers](/selfhosted/maintenance/scaling/set-up-external-relays) — Deploy relay and STUN servers on separate machines and configure the main server to use them
+- [External Relays on a Commercial License](/selfhosted/enterprise/external-relays) — What differs on NetBird Enterprise, where the shared secret is also used by traffic flow
- [Migrate from SQLite to PostgreSQL](/selfhosted/maintenance/scaling/migrate-sqlite-to-postgresql) — Move the Management database to a dedicated PostgreSQL instance (optional)
- [Set Up External Signal Server](/selfhosted/maintenance/scaling/set-up-external-signal) — Extract the Signal server to its own machine (optional)
@@ -46,6 +47,7 @@ The **relay servers** run independently on different machines, each handling rel
| `NB_LETSENCRYPT_EMAIL` | No | Email for Let's Encrypt notifications |
| `NB_TLS_CERT_FILE` | No | Path to TLS certificate (alternative to Let's Encrypt) |
| `NB_TLS_KEY_FILE` | No | Path to TLS private key |
+| `NB_TRUSTED_PROXIES` | No | Comma-separated IPs or CIDRs whose `X-Real-Ip` and `X-Real-Port` headers the relay trusts. Since v0.75.0 |
| `NB_LOG_LEVEL` | No | Log level: `debug`, `info`, `warn`, `error` |
### Main Server config.yaml - External Services
diff --git a/src/pages/selfhosted/maintenance/scaling/set-up-external-relays.mdx b/src/pages/selfhosted/maintenance/scaling/set-up-external-relays.mdx
index f714e040..4333a7ce 100644
--- a/src/pages/selfhosted/maintenance/scaling/set-up-external-relays.mdx
+++ b/src/pages/selfhosted/maintenance/scaling/set-up-external-relays.mdx
@@ -4,15 +4,25 @@ import {Note, Warning} from "@/components/mdx";
This guide is part of the [Splitting Your Self-Hosted Deployment](/selfhosted/maintenance/scaling/scaling-your-self-hosted-deployment) guide. It covers deploying external relay and STUN servers and configuring your main server to use them.
+
+Running NetBird Enterprise with a commercial license? Use [External Relays on a Commercial License](/selfhosted/enterprise/external-relays) instead. It is the same procedure written for an Enterprise deployment, and it is complete on its own. Following this page would have you generate a new shared secret, which on an Enterprise deployment with traffic flow stops traffic event logging without breaking connectivity.
+
+
For each relay server you want to deploy:
## Server Requirements
-- A Linux VM with at least **1 CPU** and **1GB RAM**
+- A Linux VM with at least **1 CPU** and **1 GB RAM**
- Public IP address
- A domain name pointing to the server (e.g., `relay-us.example.com`)
- Docker installed
-- Firewall ports open: **80/tcp** (Let's Encrypt HTTP challenge), **443/tcp** (relay), and **3478/udp** (STUN). If you configure multiple STUN ports, open all of them
+- Firewall ports open: **443/tcp** and **443/udp** (relay), and **3478/udp** (STUN). If you configure multiple STUN ports, open all of them
+
+
+**No inbound port 80 is needed.** A relay proves its domain to Let's Encrypt over 443 itself, using the TLS-ALPN-01 challenge. Your main NetBird server is different and does still need 80, and so may a proxy in front of the relay.
+
+**Both 443 rules matter.** The relay serves two transports on that port: WebSocket over TCP and QUIC over UDP. Clients race both and use whichever connects first. With 443/udp closed the QUIC attempt runs until it times out, WebSocket carries the connection, and nothing reports an error, so the missing rule is easy to overlook. A relay behind a TLS-terminating reverse proxy is the exception: it cannot serve QUIC, so it does not need 443/udp. See [Running the Relay Behind a Proxy or Load Balancer](#running-the-relay-behind-a-proxy-or-load-balancer).
+
## Generate Authentication Secret
@@ -23,7 +33,9 @@ All relay servers must share the same authentication secret with your main serve
openssl rand -base64 32
```
-Save this secret - you'll need it for both the relay servers and your main server's config.
+Save this secret. The identical value goes in two settings: `NB_AUTH_SECRET` on every relay server, and `relays.secret` on your main server.
+
+Paste it from one source into each file rather than retyping it. A single wrong character produces a relay that looks healthy and refuses every peer.
## Create Relay Configuration
@@ -42,9 +54,6 @@ NB_LISTEN_ADDRESS=:443
NB_EXPOSED_ADDRESS=rels://relay-us.example.com:443
NB_AUTH_SECRET=your-shared-secret-here
-# Only when a reverse proxy or load balancer is directly in front of the relay
-# NB_TRUSTED_PROXIES=10.20.0.10,10.20.1.0/24
-
# TLS via Let's Encrypt (automatic certificate provisioning)
NB_LETSENCRYPT_DOMAINS=relay-us.example.com
NB_LETSENCRYPT_EMAIL=admin@example.com
@@ -59,6 +68,14 @@ NB_STUN_PORTS=3478
Replace `relay-us.example.com` with your relay server's domain and `your-shared-secret-here` with the secret you generated.
+The file holds the shared secret, so make it readable by root only:
+
+```bash
+chmod 600 relay.env
+```
+
+The relay reads `relay.env` when its container is created. If you change the file later on a running relay, apply it with `docker compose up -d`. `docker compose restart` keeps the old values.
+
Create `docker-compose.yml`:
```yaml
@@ -68,7 +85,10 @@ services:
container_name: netbird-relay
restart: unless-stopped
ports:
- - '443:443'
+ # Both relay transports: WebSocket over TCP and QUIC over UDP.
+ # Docker treats a bare '443:443' as TCP only, so the UDP line is required.
+ - '443:443/tcp'
+ - '443:443/udp'
# Expose all ports listed in NB_STUN_PORTS
- '3478:3478/udp'
env_file:
@@ -87,31 +107,68 @@ volumes:
## Running the Relay Behind a Proxy or Load Balancer
-When a reverse proxy or load balancer terminates the Relay WebSocket connection, set `NB_TRUSTED_PROXIES` to the IP addresses or CIDRs of the proxies that connect **directly** to the relay:
+In this shape your proxy holds the certificate and the relay serves plain HTTP behind it.
+
+Use this shape only because a load balancer or proxy is already in front of your relays. Do not add one just to terminate TLS on the relay host: the relay already does that itself, and a proxy in front of it costs you QUIC.
+
+**On the relay host**, `relay.env` has no `NB_LETSENCRYPT_*` lines, listens on an internal port, and names the proxy's address:
```bash
-NB_TRUSTED_PROXIES=10.20.0.10,10.20.1.0/24
+NB_LOG_LEVEL=info
+NB_LISTEN_ADDRESS=:8080
+NB_EXPOSED_ADDRESS=rels://relay-us.example.com:443
+NB_AUTH_SECRET=your-shared-secret-here
+NB_TRUSTED_PROXIES=10.20.0.10
+NB_ENABLE_STUN=true
+NB_STUN_PORTS=3478
```
-The proxy must strip any client-supplied `X-Real-Ip` and `X-Real-Port`, then overwrite both headers with the address and source port from the validated client connection. The relay accepts those headers only when the immediate connection source matches the trusted list. If either header is missing, the list is empty, or the immediate source is not trusted, the relay ignores the headers and uses the direct connection address.
+Keep `rels://` written out in `NB_EXPOSED_ADDRESS`. With no TLS of its own, the relay would otherwise advertise `rel://`.
+
+In `docker-compose.yml`, remove the two `443` port lines and keep `3478:3478/udp` for STUN. Make port 8080 reachable from the proxy and nothing else. If the proxy is on another machine, publish the port with `'8080:8080/tcp'` and allow it from the proxy's address only, at a network firewall or cloud security group. A host firewall such as UFW or firewalld does not restrict it, because Docker-published ports bypass those rules.
+
+**Your proxy must:**
+
+- hold a certificate for the relay's domain, and accept connections on 443/tcp;
+- forward WebSocket upgrades over HTTP/1.1 to the relay's port 8080;
+- set both `X-Real-Ip` and `X-Real-Port` to the address and source port of the connection it received, overwriting anything the client sent.
+
+The relay uses those two headers only when the connection comes from an address in `NB_TRUSTED_PROXIES`, and only when both are present. A proxy that sets only `X-Real-Ip` gets its own address logged for every peer. A proxy that passes the client's headers through lets any client write the address of its choice into your relay logs.
+
+`NB_TRUSTED_PROXIES` is the address the relay sees the proxy's connections come from, which is not always the proxy's public address. When the proxy and relay share a Docker network, it is the proxy container's address on that network. When the proxy is another machine on the same private network, it is that machine's private address: a published Docker port keeps the original source address.
-Trust only the smallest proxy IP ranges you control. Do not trust `0.0.0.0/0`, `::/0`, or a shared network where untrusted systems can connect directly to the relay and forge forwarding headers. Invalid IP or CIDR entries prevent the relay from starting.
+Trust only the smallest proxy ranges you control. Never `0.0.0.0/0`, `::/0`, or a shared network where an untrusted system could reach the relay directly and forge these headers. Entries are IP addresses or CIDRs separated by commas. Hostnames are rejected, and a prefix such as `10.20.0.10/24` trusts the whole `/24`.
-The CLI equivalent is `--trusted-proxies`. Restart the relay after changing the list and check its startup logs for parsing errors.
+These headers decide only which client address the relay records, including in the `invalid signature` lines used for troubleshooting. Authentication does not depend on them.
+
+Apply a change to `NB_TRUSTED_PROXIES` with `docker compose up -d relay`. An invalid entry stops the relay: it exits with `failed to parse trusted proxies: ...` and restarts in a loop until you fix it. To confirm the setting works, check that the relay logs your peers' addresses rather than the proxy's:
+
+```bash
+docker compose logs relay | grep 'WS client connected from'
+```
+
+The CLI equivalent of `NB_TRUSTED_PROXIES` is `--trusted-proxies`, available since **v0.75.0**.
+
+At startup the relay reports that it cannot serve QUIC:
+
+```
+WARN relay/server/server.go:78: Not starting QUIC listener: valid TLS config is required for QUIC listener
+```
+
+That is expected here. Peers connect over WebSocket, so neither the proxy nor the relay host needs 443/udp. The proxy needs 443/tcp, plus whatever it uses to obtain its own certificate, often 80/tcp. The relay host needs 3478/udp for STUN.
## Alternative: TLS with Existing Certificates
-If you have existing TLS certificates (e.g., from your own CA or a wildcard cert), replace the Let's Encrypt variables in `relay.env` with:
+If you have existing TLS certificates (e.g., a wildcard certificate, or one from your own CA), delete the three `NB_LETSENCRYPT_*` lines from `relay.env` and add:
```bash
-# Replace the NB_LETSENCRYPT_* lines with:
NB_TLS_CERT_FILE=/certs/fullchain.pem
NB_TLS_KEY_FILE=/certs/privkey.pem
```
-And add a certificate volume to `docker-compose.yml`:
+Then replace the relay service's `volumes:` list in `docker-compose.yml` with this one. Do not add it as a second `volumes:` key, which makes the file invalid:
```yaml
volumes:
@@ -119,6 +176,13 @@ And add a certificate volume to `docker-compose.yml`:
- relay_data:/data
```
+`fullchain.pem` is your certificate followed by any intermediates, and the certificate must cover the relay's domain. Four things catch people out:
+
+- **Set both variables, and remove every `NB_LETSENCRYPT_*` line.** If a Let's Encrypt line remains, the relay uses Let's Encrypt and ignores your files, and the `curl -v` check below shows a Let's Encrypt issuer instead of yours. If only one `NB_TLS_*` variable is set, the relay starts with no TLS at all, and the sign is a missing QUIC line at startup.
+- **Nothing in the startup log names your certificate.** The `curl -v` check below is how you confirm it is the one being served.
+- **The files are read once, at startup.** Restart the relay after every renewal: `docker compose restart relay`.
+- **A private CA must be trusted by every peer.** NetBird checks the relay's certificate against each device's operating-system trust store, and has no setting for a separate CA. A device that does not trust yours reports the relay as `Unavailable` with `x509: certificate signed by unknown authority`, and cannot relay to other peers. Distribute the CA to every device before you switch.
+
## Start the Relay Server
```bash
@@ -131,19 +195,26 @@ Verify it's running:
docker compose logs -f
```
-You should see:
+You should see the relay announce its exposed address, both listeners, and the STUN server:
+
```
-level=info msg="Starting relay server on :443"
-level=info msg="Starting STUN server on port 3478"
+INFO relay/cmd/root.go:242: server will be available on: rels://relay-us.example.com:443
+INFO relay/server/listener/ws/listener.go:51: WS server listening address: :443
+INFO relay/server/listener/quic/listener.go:39: QUIC server listening on address: :443
+INFO [component: stun] stun/server.go:71: STUN server listening on [::]:3478
```
+Other lines appear alongside these, including the Let's Encrypt setup, the health check server and the metrics server. The order changes from one start to the next, because the listeners come up concurrently. What matters is that all four lines are present, not where they sit.
+
+A missing QUIC line means the relay has no TLS configuration of its own, which is expected only behind a TLS-terminating proxy.
+
If you configured Let's Encrypt, the relay generates TLS certificates lazily on the first incoming request. Trigger certificate provisioning and verify it by running:
```bash
curl -v https://relay-us.example.com/
```
-A `404 page not found` response is expected — what matters is that the TLS handshake succeeds. Look for a valid Let's Encrypt certificate in the output:
+A `404 page not found` response is expected. What matters is that the TLS handshake succeeds:
```
* Server certificate:
@@ -152,6 +223,18 @@ A `404 page not found` response is expected — what matters is that the TLS han
* SSL certificate verify ok.
```
+The issuer's `CN` names whichever intermediate signed your certificate, so yours will often differ.
+
+Two things can go wrong here, and they look different. If the first attempt fails with a TLS error such as `SSL_ERROR_SYSCALL`, wait a few seconds and run it again: that first request is what triggers issuance, and it can time out while that happens.
+
+If instead the command hangs with no output and no error, issuance is stuck rather than slow, and only the relay can tell you why:
+
+```bash
+docker compose logs relay | grep -i acme
+```
+
+A rate limit is the likeliest cause if you have rebuilt the same relay host several times. Let's Encrypt allows five certificates per week for one exact set of domain names.
+
## Repeat for Additional Relay Servers
If deploying multiple relays (e.g., for different regions), repeat the steps above on each server. Use the **same `NB_AUTH_SECRET`** but update the domain name for each.
@@ -169,15 +252,23 @@ cd ~/netbird # or wherever your deployment is
nano config.yaml
```
-Remove the `authSecret` from the `server` section and add `relays` and `stuns` sections pointing to your external servers. The presence of the `relays` section disables both the embedded relay and the embedded STUN server, so the `stuns` section is required to provide external STUN addresses:
+Add `stuns` and `relays` inside the existing `server:` block, at the same indentation as the keys already there. Anywhere inside that block works.
+
+This is an addition, not a replacement. Leave every key already under `server:` exactly as it is. A quickstart deployment also carries `auth`, `reverseProxy` and `store` sections there, holding your OIDC redirect URIs, your reverse-proxy trust settings and your datastore encryption key. Overwriting any of them breaks the deployment, so do not paste the example below over your existing block.
+
+The presence of `relays.addresses` is what disables the embedded relay, and it disables the embedded STUN server too, so the `stuns` section is required to provide external STUN addresses.
+
+You do not need to remove `server.authSecret`. It is required only when the embedded relay is running. Leaving it in place is also the safer choice on a NetBird Enterprise deployment, where the same value has a second job: see [External Relays on a Commercial License](/selfhosted/enterprise/external-relays).
+
+Both `relays` and `stuns` are available since **v0.65.0**.
```yaml
server:
listenAddress: ":80"
exposedAddress: "https://netbird.example.com:443"
- # Remove authSecret to disable the embedded relay
- # authSecret: ...
- # Remove or comment out stunPorts since we're using external STUN
+ # Leave authSecret as it is. relays.addresses below is what disables the embedded relay.
+ authSecret: "your-existing-secret"
+ # stunPorts no longer applies once stuns is set, so you can comment it out
# stunPorts:
# - 3478
metricsPort: 9090
@@ -201,18 +292,20 @@ server:
secret: "your-shared-secret-here"
credentialsTTL: "24h"
- auth:
- issuer: "https://netbird.example.com/oauth2"
- # ... rest of auth config
+ # ... the rest of your existing configuration is unchanged
```
-The `secret` under `relays` and the `NB_AUTH_SECRET` on all relay servers **must be identical**. Mismatched secrets will cause relay connections to fail silently.
+The `secret` under `relays` and the `NB_AUTH_SECRET` on every relay server **must be identical**. If they differ, peers cannot use that relay.
+A mismatch is easiest to spot on the relay host, which logs `failed to handshake: ... invalid signature` for every rejected peer.
+
+It is harder to spot from a peer, because a peer lists only the relays it is actually using. One bad relay among several never appears as a failure: the peer picks a working one instead, and the broken relay is simply absent. Only when every relay rejects does `netbird status -d` report `Unavailable, reason: failed to get reader: failed to read frame header: EOF`. The `stun:` entry stays `Available` throughout, because STUN is unauthenticated.
+
### Update docker-compose.yml (Optional)
-If your main server was exposing STUN port 3478, you can remove it since STUN is now handled by external relays:
+If your main server was exposing STUN port 3478, you can remove it, since STUN is now handled by external relays. Change only the `ports` entry and leave the rest of the service as it is:
```yaml
netbird-server:
@@ -227,15 +320,53 @@ If your main server was exposing STUN port 3478, you can remove it since STUN is
- netbird_data:/var/lib/netbird
- ./config.yaml:/etc/netbird/config.yaml
command: ["--config", "/etc/netbird/config.yaml"]
+ # ... the rest of this service, including its labels, is unchanged
```
### Restart the Main Server
+Which command you need depends on whether you did the optional `docker-compose.yml` step above.
+
+**If you edited `config.yaml` only**, restart the container:
+
```bash
-docker compose down
-docker compose up -d
+docker compose restart netbird-server
```
+`config.yaml` is a bind mount, so editing it does not change anything Docker Compose compares. `docker compose up -d` would report the service as already up to date, and the server would keep serving the old configuration.
+
+**If you also removed the STUN port from `docker-compose.yml`**, recreate the container instead:
+
+```bash
+docker compose up -d netbird-server
+```
+
+A published port is fixed when the container is created, so `docker compose restart` cannot remove one: the container comes back with the mapping still in place and nothing reports a problem. Recreating the container applies both files at once, so this one command covers the `config.yaml` change as well.
+
+
+Avoid `docker compose down` here. It stops every other service too, including your reverse proxy and, on larger deployments, the database and the traffic-flow services.
+
+
+## The Embedded Relay Is Now Off
+
+Setting `relays.addresses` switches the embedded relay off. There is no setting that keeps it running alongside external relays, and listing the main server's own address among them does not bring it back: clients are handed an address that never answers.
+
+```bash
+curl https://netbird.example.com/relay
+```
+
+```
+Relay service not enabled
+```
+
+The endpoint answers with HTTP 404 and that single line as its body. Add `-i` to the command if you want to see the status code as well.
+
+From here on, relay capacity is whatever your external relay hosts provide. Size them accordingly.
+
+
+This is a cutover, not a gradual migration. The restart that applies the change is also the restart that drops every connection the embedded relay was serving, so those peers reconnect straight onto the external relays. Bring the relay hosts up and confirm they answer before you edit `config.yaml`, because there is no period where both are available.
+
+
## Verify the Configuration
### Check Main Server Logs
@@ -256,6 +387,8 @@ INFO combined/cmd/root.go: Relay: false (log level: )
Relay addresses: [rels://relay-us.example.com:443 rels://relay-eu.example.com:443]
```
+The address list is logged twice, from two different places in the server. That is normal.
+
### Check Peer Status
Connect a NetBird client and verify that both STUN and relay services are available:
@@ -264,24 +397,31 @@ Connect a NetBird client and verify that both STUN and relay services are availa
netbird status -d
```
-The output should list your external STUN and relay servers. All configured STUN servers will appear, but only one randomly chosen relay is used per client:
+The output lists your external STUN and relay servers. Every configured STUN server appears. Relays work differently: the client dials them in parallel and keeps the first to answer, which is normally the nearest. That one becomes its **home relay**, so relays in several regions give each client the nearest one automatically.
```
Relays:
[stun:relay-us.example.com:3478] is Available
[stun:relay-eu.example.com:3478] is Available
- [rels://relay-eu.example.com:443] is Available
+ [rels://relay-eu.example.com:443] is Available via ws
```
-You can also test failover by stopping one of the relay servers and checking the status again. The client will detect the unavailable server and use the remaining one:
+You may see more than one `rels://` line. A client also connects to another peer's home relay when that peer picked a different one, so holding two relay connections at once is normal in a multi-region deployment.
+
+The suffix names the transport that won the race, and it is `ws` or `quic` depending on which answered first. Both are normal, the winner can differ between two peers on the same deployment, and neither is a sign of a problem. What 443/udp buys you is that the QUIC attempt can win or lose on merit rather than sitting until it times out.
+
+### Test Failover
+
+Stop the relay a peer is actually using, which is the one on its `rels://` line, otherwise nothing observable changes. Then check that peer again.
+
+Read the `rels://` line, not the `stun:` lines. It is the one that shows failover working: the stopped relay drops out of it and the surviving relay carries the traffic.
```
-Relays:
- [stun:relay-us.example.com:3478] is Available
- [stun:relay-eu.example.com:3478] is Unavailable, reason: stun request: context deadline exceeded
- [rels://relay-us.example.com:443] is Available
+ [rels://relay-us.example.com:443] is Available via ws
```
+The `stun:` entries are not a dependable failover signal. Depending on circumstances they either report the stopped server as `Unavailable` with a reason, or sit at `Checking...` for every configured STUN server, including healthy ones, without clearing on their own or after a client restart. Both have been observed. Either way they return to `Available` once the stopped relay is back, so do not read `Checking...` as a second failure.
+
### Test Relay Connectivity
You can force all peer connections through relay to verify it works end-to-end. On a client, run:
diff --git a/src/pages/selfhosted/maintenance/upgrade.mdx b/src/pages/selfhosted/maintenance/upgrade.mdx
index c0234174..82cd3fbf 100644
--- a/src/pages/selfhosted/maintenance/upgrade.mdx
+++ b/src/pages/selfhosted/maintenance/upgrade.mdx
@@ -1,5 +1,7 @@
# Upgrade Your Self-Hosted NetBird Installation
+This guide covers NetBird upgrades and [updating Traefik and CrowdSec](#upgrading-traefik-and-crowd-sec), including deployments created from marketplace images. Run the commands from the directory containing your `docker-compose.yml` (`/opt/netbird` on [Vultr](/selfhosted/marketplaces/vultr)).
+
## Check for Updates
The NetBird Dashboard displays an update indicator at the bottom of the left navigation menu when a new version is available:
@@ -27,6 +29,8 @@ Example response:
}
```
+The dashboard indicator and version API cover NetBird components. They do not report updates for Traefik, CrowdSec, or the optional Traefik CrowdSec bouncer plugin. Check those projects' releases separately, as described below.
+
## Check Release Notes
Before upgrading, review the release notes for any breaking changes or migration steps:
@@ -44,7 +48,8 @@ To upgrade NetBird to the latest version:
1. Run the backup steps described in the [backup](/selfhosted/maintenance/backup) section.
2. Review the release notes (see above) for any breaking changes.
-3. Pull the latest NetBird docker images:
+3. Check the `image:` entries in `docker-compose.yml`. If you use explicit version tags, change them to the releases you want to deploy before pulling. `docker compose pull` downloads the configured tags; it does not change them. With `:latest`, it fetches the image currently published under that tag.
+4. Pull the configured NetBird images:
```bash
docker compose pull netbird-server dashboard
```
@@ -52,7 +57,7 @@ To upgrade NetBird to the latest version:
```bash
docker compose pull proxy
```
-4. Restart the NetBird containers with the new images:
+5. Restart the NetBird containers with the new images:
```bash
docker compose up -d --force-recreate netbird-server dashboard
```
@@ -72,11 +77,160 @@ Compare it with the `management_current_version` field returned by `GET /api/ins
-For upgrades from older versions (pre-v0.26.0), see the [Legacy upgrade notes](#legacy-self-hosting-with-zitadel-idp) below.
+For upgrades from older versions (pre-v0.26.0), see the [legacy upgrade notes](/selfhosted/selfhosted-guide#upgrade).
+## Upgrading Traefik and CrowdSec
+
+Traefik and CrowdSec have their own release cycles. Upgrading NetBird does not update their version tags or apply changes from newer installation scripts to your existing Compose file. Use your existing configuration for upgrades; rerunning `getting-started.sh` is not an upgrade procedure.
+
+### Choosing versions
+
+The following dependency versions have been validated for self-hosted NetBird as of **October 9, 2026**:
+
+| Component | Validated version |
+| --- | --- |
+| Bundled Traefik | `traefik:v3.7.14` |
+| CrowdSec | `crowdsecurity/crowdsec:v1.8.1` |
+| Traefik CrowdSec bouncer plugin | `v1.7.1` |
+
+Installer defaults can differ from these versions. Check the image tags in your `docker-compose.yml`; the bouncer plugin is configured separately in the [dashboard AppSec guide](/selfhosted/maintenance/crowdsec-dashboard#update-docker-compose-yml). Before adopting a newer release, review its migration requirements and verify it with your NetBird configuration in a test deployment.
+
+- **Traefik:** check the [releases](https://github.com/traefik/traefik/releases), [support policy](https://doc.traefik.io/traefik/deprecation/releases/), and [v3 migration notes](https://doc.traefik.io/traefik/migrate/v3/). Configuration changes can be required within the same major version.
+- **CrowdSec:** check the [releases](https://github.com/crowdsecurity/crowdsec/releases) and [Docker documentation](https://docs.crowdsec.net/u/getting_started/installation/docker/).
+- **Dashboard AppSec:** also check the [Traefik bouncer plugin releases](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/releases). The plugin version is set by `--experimental.plugins.bouncer.version` in Traefik's `command:` list. Changing the CrowdSec image does not update it.
+
+You can subscribe to release notifications on each GitHub repository using **Watch → Custom → Releases**. Check security advisories and upstream support status as well as the installer defaults when planning updates.
+
+Full version tags such as `traefik:v3.7.14` and `crowdsecurity/crowdsec:v1.8.1` select specific releases. A minor-version tag such as `traefik:v3.6` can fetch a newer patch image without a Compose edit. For controlled rollouts, use full version tags for all images, or image digests when you need immutable references, and update them deliberately after validation.
+
+### Before changing a version
+
+1. Record the configured images and the images currently in use:
+
+ ```bash
+ docker compose config --images
+ docker compose images
+ docker compose exec traefik traefik version
+ ```
+
+ If CrowdSec is enabled, also run:
+
+ ```bash
+ docker compose exec crowdsec cscli version
+ ```
+
+ Record the current plugin version if you use dashboard AppSec. For each image, save its digest for rollback; use an image ID from `docker compose images`:
+
+ ```bash
+ docker image inspect --format '{{json .RepoDigests}}'
+ ```
+
+2. [Back up the deployment](/selfhosted/maintenance/backup), including the Compose file, environment files, Traefik dynamic configuration, CrowdSec configuration and database, and certificate volumes. If you enabled dashboard AppSec, also back up the Compose project `.env` file containing its bouncer key and the `crowdsec/acquis.d/` configuration.
+3. Review release notes for every version you are skipping, then test the candidate versions with the [checks below](#verify-the-upgrade). Keep SSH access available independently of the dashboard and proxy while testing blocks or restarting services.
+
+### Apply the update
+
+Change one component at a time so you can identify regressions. Edit that service's `image:` in `docker-compose.yml` to the version or digest you selected, preserving its existing environment, labels, networks, and volume mounts. If the image is supplied through a Compose variable, update that variable instead.
+
+For Traefik, also check [header handling](#traefik-header-handling) and the [Docker network setting](#traefik-docker-network) before recreating the container.
+
+Validate the configuration before applying it:
+
+```bash
+docker compose config --quiet
+```
+
+For CrowdSec, pull and recreate only its service:
+
+```bash
+docker compose pull crowdsec
+docker compose up -d --no-deps crowdsec
+docker compose ps crowdsec
+docker compose logs --tail=100 crowdsec
+```
+
+Confirm CrowdSec is healthy and repeat the blocking checks below before continuing. AppSec-protected requests can be denied while CrowdSec is unavailable.
+
+For Traefik, pull and recreate its service after editing its image:
+
+```bash
+docker compose pull traefik
+docker compose up -d --no-deps traefik
+docker compose logs --tail=100 traefik
+```
+
+If you change only the bouncer plugin version, apply the updated Traefik `command:` with `docker compose up -d --no-deps traefik`. A plain `docker compose restart` does not apply Compose configuration changes.
+
+These restarts briefly interrupt the affected services. Preserve the existing CrowdSec database, bouncer keys, and certificate volumes; do not use `docker compose down -v` during an upgrade.
+
+### Verify the upgrade
+
+A running container alone does not confirm that routing, certificate issuance, or blocking works. Check the features enabled in your deployment:
+
+| Area | What to verify |
+| --- | --- |
+| Dashboard and peers | Sign in, load peers and policies, connect an existing peer, enroll a test peer, and verify peer connectivity and relay fallback. |
+| Reverse proxy | Reach an existing service and create a test service. Exercise the protocols, authentication methods, and access restrictions you use, including HTTP, WebSockets, TCP/UDP/TLS services, and Traefik TLS passthrough where configured. |
+| Certificates | Check trusted HTTPS on the dashboard and proxied domains without disabling certificate verification. Use a new test hostname to verify certificate issuance, and check ACME logs for failures. An existing valid certificate alone does not verify issuance or renewal; exercise renewal in the test deployment and monitor it after rollout. See [Certificate troubleshooting](/selfhosted/troubleshooting/certificates). |
+| Client IPs | Confirm proxy access logs contain the original client IP, so IP restrictions and CrowdSec decisions apply to the correct address. |
+| CrowdSec IP reputation | Follow [Testing the integration](/selfhosted/maintenance/crowdsec#testing-the-integration): confirm the bouncer syncs, a temporary decision blocks in enforce mode, observe mode allows and logs the verdict, and access returns after removing the decision. |
+| Dashboard AppSec | If configured, repeat [Verify protection](/selfhosted/maintenance/crowdsec-dashboard#verify-protection) and [the middleware checks](/selfhosted/maintenance/crowdsec-dashboard#verifying-the-middleware-stays-attached). Normal dashboard and OIDC requests should succeed; `/.env` and `/api/.env` probes should return `403`, with AppSec metrics confirming inspection. Test dashboard IP blocking separately. |
+| Persistence | Restart the updated services and repeat the checks. Confirm certificates, CrowdSec enrollment, bouncer authentication, and AppSec configuration survive the restart. |
+
+Record the exact image versions or digests, plugin version, and test results with your deployment configuration so the next upgrade has a known working starting point.
+
+### Roll back a dependency update
+
+Restore the previous image digest or full version tag and any configuration changed for that component, including the bouncer plugin version. Recreate the affected service with `docker compose up -d --no-deps ` and repeat the verification checks. Restoring a moving tag such as `v3.6` or `latest` may fetch the upgraded image again; use the recorded digest to recover the exact previous image.
+
+When rolling Traefik back to a release older than `v3.7.12`, also remove the `aliasHeadersStrategy` flags added in [Traefik header handling](#traefik-header-handling). Those releases do not recognize the option and fail to start with `field not found, node: aliasHeadersStrategy`.
+
+
+Traefik v3.0.0 through v3.7.11 are affected by header-alias spoofing ([GHSA-rf44-j88r-hh8c](https://github.com/traefik/traefik/security/advisories/GHSA-rf44-j88r-hh8c)). Removing `aliasHeadersStrategy` so those releases can start restores that vulnerable handling: a client can send an alias of a header Traefik wrote, and a backend that treats those names as the same variable can read the client's value instead. Do not expose an affected rollback version to untrusted traffic. Return to `v3.7.12` or newer, with `aliasHeadersStrategy` set to `delete`, once service is restored.
+
+
+If the release migrated stored data or configuration in a way the older version cannot read, stop the affected service and [restore its pre-upgrade backup](/selfhosted/maintenance/backup) before starting the older image. Changing an image tag does not undo a database migration.
+
## Migration Notes
+### Traefik header handling
+
+Traefik v3.7.12 introduced `aliasHeadersStrategy` and warns when it is unset. When upgrading to the validated `v3.7.14` version, add these settings to the existing `services.traefik.command` list in `docker-compose.yml`:
+
+```yaml
+ - '--entrypoints.web.http.aliasHeadersStrategy=delete'
+ - '--entrypoints.websecure.http.aliasHeadersStrategy=delete'
+```
+
+These settings apply to HTTP traffic on both entry points, whether or not CrowdSec is enabled. Replace any existing `underscoreHeadersStrategy` flags with these settings. The `delete` strategy helps prevent header spoofing by removing request headers whose names contain characters other than letters, digits, and hyphens. It also removes legitimate headers such as `X_Request_Id`, so check custom integrations before enabling it. See [Traefik's header handling documentation](https://doc.traefik.io/traefik/reference/install-configuration/entrypoints/#aliasheadersstrategy).
+
+### Traefik Docker network
+
+Some installer-generated Compose files set `--providers.docker.network=netbird`, while Compose creates a network named `_netbird`. Traefik then warns and falls back to another network. This usually works with one shared network but can select the wrong one when containers have several.
+
+Check the networks attached to the bundled Traefik container:
+
+```bash
+docker inspect netbird-traefik --format '{{range $name, $_ := .NetworkSettings.Networks}}{{println $name}}{{end}}'
+```
+
+Replace the existing `--providers.docker.network` value with the shared network's actual name. For example, if the command prints `netbird_netbird`:
+
+```yaml
+ - '--providers.docker.network=netbird_netbird'
+```
+
+The name depends on the Compose project. An explicitly named network, such as `netbird` in the enterprise installer, does not receive a project prefix. Keep that name if it already matches. See [Docker's network naming documentation](https://docs.docker.com/reference/compose-file/networks/#name).
+
+After changing either Traefik setting, validate the Compose file and recreate Traefik:
+
+```bash
+docker compose config --quiet
+docker compose up -d --no-deps traefik
+docker compose logs --tail=100 traefik
+```
+
### Set `reverseProxy.trustedPeers` on older deployments
Deployments created with the Quickstart script (or a marketplace image based on it, such as [Vultr](/selfhosted/marketplaces/vultr)) before **September 2026** are missing the `reverseProxy.trustedPeers` setting in the generated `config.yaml`. Without it, server versions up to v0.79 record your reverse proxy's address instead of the real client IP for each peer connection. Versions after v0.79 ([netbird#7589](https://github.com/netbirdio/netbird/pull/7589)) instead fall back to accepting forwarded client-IP headers from **any** source and log a spoofing warning at startup.
diff --git a/src/pages/selfhosted/marketplaces/vultr.mdx b/src/pages/selfhosted/marketplaces/vultr.mdx
index d64b092e..eb5440a7 100644
--- a/src/pages/selfhosted/marketplaces/vultr.mdx
+++ b/src/pages/selfhosted/marketplaces/vultr.mdx
@@ -72,15 +72,16 @@ The Vultr instance ships with a root password. Before relying on the deployment,
## Maintaining your deployment
-The NetBird stack lives in **`/opt/netbird`** on the instance, including the `docker-compose.yml` and environment files. If the dashboard shows an **Update available** banner, SSH in and upgrade by pulling the latest images and restarting the containers:
+The NetBird stack lives in **`/opt/netbird`** on the instance, including the `docker-compose.yml` and environment files. SSH in and run the [Upgrade guide](/selfhosted/maintenance/upgrade) from that directory, starting with a backup and release-note review:
```bash
cd /opt/netbird
-docker compose pull
-docker compose up -d
+docker compose config --images
```
-Refresh the dashboard and the banner should clear. See the [Upgrade guide](/selfhosted/maintenance/upgrade) for the full procedure, including migration notes and rollback steps.
+The dashboard's **Update available** indicator covers NetBird components. Traefik, CrowdSec, and the optional dashboard AppSec plugin need separate update checks. Follow [Upgrading Traefik and CrowdSec](/selfhosted/maintenance/upgrade#upgrading-traefik-and-crowd-sec) for version selection, targeted updates, verification, and rollback.
+
+An updated marketplace image does not change an existing instance's Compose file or image tags. Maintain the running deployment with these steps rather than redeploying it from a newer image.
## Next steps
diff --git a/src/pages/selfhosted/migration/enable-reverse-proxy.mdx b/src/pages/selfhosted/migration/enable-reverse-proxy.mdx
index 788fd1d7..ece375b2 100644
--- a/src/pages/selfhosted/migration/enable-reverse-proxy.mdx
+++ b/src/pages/selfhosted/migration/enable-reverse-proxy.mdx
@@ -42,6 +42,7 @@ Before starting, ensure you have:
- **A domain for the proxy** - preferably a dedicated domain such as `proxy.example.com`. It can share the NetBird server's domain, but that base hostname remains reserved for the dashboard and management server. Proxied services must use subdomains (e.g., `myapp.netbird.example.com`)
- **Wildcard DNS capability** - ability to create a wildcard record such as `*.proxy.example.com` pointing to your server
- **Port 443 accessible** - the proxy needs this for ACME TLS-ALPN-01 challenges (certificate provisioning)
+- **The server can reach its own public IP** - the proxy runs its own NetBird client, and that client connects to Signal and the relay at your NetBird domain's public address, even when the proxy reaches the management server over the Docker network. If the firewall in front of the server does not route a connection from the server back to its own public IP (hairpin NAT), every proxied service returns `504 Gateway Timeout` and the proxy logs `failed connecting to Signal Service`. Resolving the NetBird domain to an internal address instead (split DNS) keeps the services working, but every connection between the proxy and your peers then goes through the relay
This guide covers both the **combined container** setup (`netbirdio/netbird-server`, the default for new deployments) and the **multi-container** setup (separate `management`, `signal`, and `relay` images). Where commands or configuration differ between the two setups, both variants are shown.
@@ -270,6 +271,32 @@ docker compose up -d proxy
You only need port mappings for TCP and UDP mode services. HTTP and TLS mode services are routed through port 443 via Traefik and do not require additional port entries.
+Changing `ports` recreates the proxy container, which briefly interrupts every service it serves, not only the new one. To add L4 services without that step, run the proxy on the host network instead.
+
+#### Use host networking instead
+
+With host networking, the proxy binds each L4 listen port on the server itself, so a new TCP or UDP service is reachable as soon as you create it, with no compose change and no restart. The server's firewall then decides which ports are reachable, so open each listen port there as you add its service. The port mappings above remain the default: host networking gives up Docker's port isolation, and here the proxy shares the host's network with your NetBird server and Traefik.
+
+Two settings must change with it, because the proxy is no longer on the `netbird` Docker network:
+
+- **The management address.** `http://netbird-server:80` (or `http://management:33073` in a multi-container setup) is a Docker network name, which a container on the host network cannot resolve; the proxy logs `name resolver error: produced zero addresses` and never connects. In `proxy.env`, set `NB_PROXY_MANAGEMENT_ADDRESS` to your public NetBird URL, for example `https://netbird.example.com:443`. This connection goes through Traefik, so Traefik must route `/management.ProxyService/` and allow long-lived gRPC streams. Deployments from the current installer already do; for older ones, apply steps 1 and 3 of [Connecting through Traefik instead of Docker network](#connecting-through-traefik-instead-of-docker-network).
+- **How Traefik reaches the proxy.** For a container on the host network, Traefik dials `127.0.0.1:8443`, which inside Traefik's own container is Traefik itself, so every HTTP service fails with `connection refused`. Mapping `host.docker.internal` to the host in Traefik's service makes it reach the proxy on the host instead.
+
+In `docker-compose.yml`, remove the `networks` and `ports` entries from the `proxy` service, then add:
+
+```yaml
+proxy:
+ # ...existing configuration, without networks: and ports:...
+ network_mode: host
+
+traefik:
+ # ...existing configuration...
+ extra_hosts:
+ - host.docker.internal:host-gateway
+```
+
+Apply both with `docker compose up -d proxy traefik`. The proxy's main port stays `8443`, which an unprivileged process can bind on the host. An L4 listen port below `1024` needs the host's unprivileged port floor lowered first; see [Bring Your Own Proxy](/manage/reverse-proxy/bring-your-own-proxy#expose-tcp-and-udp-services).
+
### Step 4: Set up DNS records
Create DNS records pointing to the server running your NetBird stack: one for the base proxy domain and one wildcard for service subdomains.
@@ -331,7 +358,7 @@ Add the following service to your `docker-compose.yml`:
```yaml
crowdsec:
- image: crowdsecurity/crowdsec:v1.7.7
+ image: crowdsecurity/crowdsec:v1.8.1
container_name: netbird-crowdsec
restart: unless-stopped
networks: [netbird]
@@ -731,6 +758,7 @@ The proxy is configured through environment variables (each one maps to an equiv
| `NB_PROXY_SUPPORTS_CUSTOM_PORTS` | No | Whether the proxy can bind arbitrary ports for UDP/TCP passthrough. | `true` |
| `NB_PROXY_REQUIRE_SUBDOMAIN` | No | Require a subdomain label in front of the cluster domain. | `false` |
| `NB_PROXY_PRIVATE` | No | Serve private services with NetBird-Only authentication, reachable exclusively over the WireGuard tunnel (also enables per-account inbound listeners). Required for the **Proxy Cluster** target type and **NetBird-Only Access**. The `netbirdio/reverse-proxy` image runs in embedded mode by default, so setting this on a standard self-hosted deployment is supported: the cluster then reports the `Private` capability and the dashboard **Clusters** page shows a **Private** badge. | `false` |
+| `NB_PROXY_DIRECT_UPSTREAM_BLOCK_PRIVATE` | No | Refuse **Direct Upstream** dials to addresses that are not publicly routable (loopback, RFC 1918, CGNAT, link-local including cloud metadata services, ULA, multicast, and reserved ranges). Hostnames are checked after DNS resolution. Refused requests return `502 Destination Not Allowed`. Enable this on proxies that serve untrusted accounts; leave it off if services need to reach LAN or localhost backends. Tunneled traffic through peers is unaffected. | `false` |
| `NB_PROXY_MAX_DIAL_TIMEOUT` | No | Cap the per-service backend dial timeout (`0` = no cap), e.g. `10s`. | `0` |
| `NB_PROXY_MAX_SESSION_IDLE_TIMEOUT` | No | Cap the per-service session idle timeout (`0` = no cap), e.g. `5m`. | `0` |
diff --git a/src/pages/selfhosted/migration/external-to-embedded-idp.mdx b/src/pages/selfhosted/migration/external-to-embedded-idp.mdx
index f8e223c3..e74b9206 100644
--- a/src/pages/selfhosted/migration/external-to-embedded-idp.mdx
+++ b/src/pages/selfhosted/migration/external-to-embedded-idp.mdx
@@ -149,6 +149,22 @@ Using Zitadel as an example, the JSON should have the following values:
- "clientID" and "clientSecret": are the values you copy when creating the OAuth app
+
+For Google, use the `google` connector type, which needs no issuer. It uses the same Google account ID as the standalone Google setup, so existing users keep their NetBird accounts:
+```json
+{
+ "type": "google",
+ "name": "Google",
+ "id": "google",
+ "config": {
+ "clientID": "my-client-id",
+ "clientSecret": "my-client-secret"
+ }
+}
+```
+In the Google Cloud Console, add `https:///oauth2/callback` to the OAuth client's **Authorized redirect URIs**.
+
+
2. Encode and store it in the `NETBIRD_IDP_SEED_INFO` environment variable:
diff --git a/src/pages/selfhosted/migration/mysql-to-postgresql.mdx b/src/pages/selfhosted/migration/mysql-to-postgresql.mdx
new file mode 100644
index 00000000..750b21b4
--- /dev/null
+++ b/src/pages/selfhosted/migration/mysql-to-postgresql.mdx
@@ -0,0 +1,164 @@
+import {Note} from "@/components/mdx"
+
+export const description = 'Move a self-hosted NetBird deployment from MySQL to PostgreSQL before MySQL support is removed.'
+
+# Migrate from MySQL to PostgreSQL
+
+MySQL support for the Management store is deprecated and will be removed in an upcoming release. This guide moves an existing MySQL deployment to PostgreSQL with the `netbird-mysql-migrate` tool.
+
+The tool copies the following into PostgreSQL:
+- the Management store, from MySQL
+- the activity events (`events.db`) and embedded IdP (`idp.db`) stores, from their SQLite files
+
+The tool only reads your MySQL database and doesn't change it.
+
+## Before You Begin
+
+- Upgrade the management server to `v0.81.0` or later and start it once on MySQL, so the database schema is current. That's the `netbird-server` service, or `management` on the older multi-container setup, not the NetBird client. Use the tool from the same release.
+- Prepare an empty PostgreSQL database. To run one in Docker, see [Set Up PostgreSQL](/selfhosted/maintenance/scaling/migrate-sqlite-to-postgresql#set-up-postgre-sql). The examples in this guide connect with `sslmode=require`; use `verify-full` to also verify the server certificate. Use `sslmode=disable` only when PostgreSQL runs on the same host or Docker network as the management server.
+- Run the tool on a Linux machine that can reach both MySQL and PostgreSQL. The tool is Linux-only, built for `amd64`, `arm64`, and `arm`.
+
+## Step 1: Get the Migration Tool
+
+```bash
+VERSION=0.81.0
+curl -L -o netbird-mysql-migrate.tar.gz \
+ https://github.com/netbirdio/netbird/releases/download/v${VERSION}/netbird-mysql-migrate_${VERSION}_linux_amd64.tar.gz
+tar xzf netbird-mysql-migrate.tar.gz
+chmod +x netbird-mysql-migrate
+```
+
+On `arm64` or `arm`, replace `linux_amd64` with `linux_arm64` or `linux_arm`.
+
+## Step 2: Stop the Management Server and Back Up
+
+Stop the management server and copy its data directory, which holds `events.db` and `idp.db`:
+
+```bash
+docker compose stop netbird-server
+mkdir backup
+docker compose cp -a netbird-server:/var/lib/netbird/. backup/
+chmod 700 backup
+```
+
+On the older multi-container setup, use the `management` service instead of `netbird-server`.
+
+Back up MySQL as well:
+
+```bash
+mysqldump --no-tablespaces -h -u -p > backup/netbird-mysql.sql
+```
+
+The backup holds password hashes from `idp.db` and a full database dump, so `chmod 700` keeps it readable only by you. `--no-tablespaces` avoids a `PROCESS` privilege error when you dump as the NetBird database user.
+
+## Step 3: Run the Migration
+
+```bash
+./netbird-mysql-migrate \
+ --mysql-dsn ':@tcp(:3306)/' \
+ --postgres-dsn 'host= port=5432 user= password= dbname= sslmode=require' \
+ --events-db backup/events.db \
+ --auth-db backup/idp.db
+```
+
+When it finishes, you see:
+
+```
+management: 1520 rows from 39 tables
+activity: 8230 rows from 2 tables
+auth: 41 rows from 12 tables
+Done. Point the store, activity and auth store settings at Postgres before starting the server.
+```
+
+
+- All stores go into the database given by `--postgres-dsn`. To keep the activity or auth store in a separate database, add `--events-postgres-dsn` or `--auth-postgres-dsn`.
+- A missing `events.db` or `idp.db` is skipped. For example, deployments on an external IdP have no `idp.db`.
+- MySQL timestamps are read as UTC. If your management server runs in another time zone, for example with `TZ` set on the container, add `--mysql-timezone`, such as `--mysql-timezone Europe/Berlin`.
+- The tool stops if the PostgreSQL database already holds NetBird data.
+
+
+## Step 4: Point the Management Server at PostgreSQL
+
+Change only the store settings. Keep the encryption key (`server.store.encryptionKey` in `config.yaml`, `DataStoreEncryptionKey` in `management.json`) as it is: the tool copies encrypted values as they are, so the management server needs the same key to read them.
+
+### Combined setup (config.yaml)
+
+Set all three stores to PostgreSQL in `config.yaml`, using the DSNs you migrated into:
+
+```yaml
+server:
+ store:
+ engine: "postgres"
+ dsn: "host= port=5432 user= password= dbname= sslmode=require"
+ activityStore:
+ engine: "postgres"
+ dsn: "host= port=5432 user= password= dbname= sslmode=require"
+ authStore:
+ engine: "postgres"
+ dsn: "host= port=5432 user= password= dbname= sslmode=require"
+```
+
+Remove any `NETBIRD_STORE_ENGINE_MYSQL_DSN` or `NB_STORE_ENGINE_MYSQL_DSN` variable from `docker-compose.yml`.
+
+### Older multi-container setup (management.json)
+
+In `management.json`, set the store engine and, if the file has an `EmbeddedIdP` section, its storage:
+
+```json
+"StoreConfig": {
+ "Engine": "postgres"
+},
+"EmbeddedIdP": {
+ "Storage": {
+ "Type": "postgres",
+ "Config": {
+ "DSN": "host= port=5432 user= password= dbname= sslmode=require"
+ }
+ }
+}
+```
+
+Then, on the `management` service in `docker-compose.yml`, replace `NETBIRD_STORE_ENGINE_MYSQL_DSN` with:
+
+```yaml
+environment:
+ - NETBIRD_STORE_ENGINE_POSTGRES_DSN=host= port=5432 user= password= dbname= sslmode=require
+ - NB_ACTIVITY_EVENT_STORE_ENGINE=postgres
+ - NB_ACTIVITY_EVENT_POSTGRES_DSN=host= port=5432 user= password= dbname= sslmode=require
+```
+
+## Step 5: Start and Verify
+
+```bash
+docker compose up -d
+```
+
+Log in to the dashboard and confirm that your peers, users, and activity events are there. Then delete the `backup` directory.
+
+## Roll Back
+
+The tool doesn't change MySQL, so to roll back, revert the store settings from Step 4 and restart the management server. Changes made while running on PostgreSQL aren't carried back to MySQL.
+
+A failed run leaves no data in PostgreSQL, so you can fix the problem and run the tool again.
+
+## Troubleshooting
+
+### Tool exits with "target already holds data"
+
+The PostgreSQL database for the store named in the error already holds NetBird data. If an earlier run of the tool finished, your data is already there, so continue with [Step 4](#step-4-point-the-management-server-at-postgre-sql). Otherwise, point the tool at an empty database.
+
+### Tool exits with "tls error: server refused TLS connection"
+
+The DSN requires TLS (`sslmode=require` or `verify-full`), but PostgreSQL doesn't accept TLS connections. Enable TLS on PostgreSQL. If PostgreSQL runs on the same host or Docker network as the management server, you can use `sslmode=disable` instead, in the tool's DSNs and in the Step 4 settings.
+
+### Tool exits with "source lacks columns"
+
+Your data was last written by an older release than the tool. Start the management server once against MySQL on the same release as the tool, then repeat [Step 2](#step-2-stop-the-management-server-and-back-up) so your backup holds the updated `events.db` and `idp.db`, and run the tool again.
+
+### Tool exits with "no NetBird tables found in the source"
+
+The error names the store it came from. For `management`, check the database name in `--mysql-dsn`. For `activity` or `auth`, check that `--events-db` or `--auth-db` points to the `events.db` or `idp.db` in your backup.
+
+### Management server doesn't start: "activityStore.dsn is required when activityStore.engine is postgres"
+
+The `activityStore` block in `config.yaml` sets `engine: "postgres"` without a `dsn`. Add the DSN as shown in [Step 4](#step-4-point-the-management-server-at-postgre-sql). You can see this error with `docker compose logs netbird-server`.
diff --git a/src/pages/selfhosted/selfhosted-guide.mdx b/src/pages/selfhosted/selfhosted-guide.mdx
index ffca268a..4486418a 100644
--- a/src/pages/selfhosted/selfhosted-guide.mdx
+++ b/src/pages/selfhosted/selfhosted-guide.mdx
@@ -139,7 +139,7 @@ NetBird supports generic OpenID (OIDC) protocol allowing integration with any ID
NetBird's management service integrates with some of the most popular IDP APIs, allowing the service to cache and display user names and email addresses without storing sensitive data.
-Pick the one that suits your needs, follow the **Standalone Setup (Advanced)** section in each guide, and continue with this guide:
+Pick the one that suits your needs, follow the **Standalone Setup (Advanced)** section in each guide (except Google, see the note below), and continue with this guide:
**Self-hosted options**
- [Zitadel](/selfhosted/identity-providers/zitadel) - Previously used in the quickstart script
@@ -149,13 +149,15 @@ Pick the one that suits your needs, follow the **Standalone Setup (Advanced)** s
**Managed options**
- [Microsoft Entra ID](/selfhosted/identity-providers/managed/microsoft-entra-id) - Azure AD / Microsoft 365
-- [Google Workspace](/selfhosted/identity-providers/managed/google-workspace) - Google accounts
+- [Google Workspace](/selfhosted/identity-providers/managed/google-workspace#management-setup-recommended) - Google accounts. Not recommended as a standalone IdP; use the Management Setup (Recommended) instead
- [Okta](/selfhosted/identity-providers/managed/okta) - Enterprise SSO
- [Auth0](/selfhosted/identity-providers/managed/auth0) - Flexible auth platform
- [JumpCloud](/selfhosted/identity-providers/managed/jumpcloud) - Cloud directory
Each provider page includes both "Management Setup (Recommended)" (for use with the embedded IdP) and "Standalone Setup (Advanced)" sections. For this guide, follow the **Standalone Setup (Advanced)** section.
+
+Google is the exception: its standalone setup is not recommended. Deploy with the [Quickstart guide](/selfhosted/selfhosted-quickstart), which uses the embedded IdP, and add Google with the [Management Setup (Recommended)](/selfhosted/identity-providers/managed/google-workspace#management-setup-recommended) section instead of continuing with this guide.
### Step 4: Disable single account mode (optional)
diff --git a/src/pages/selfhosted/selfhosted-quickstart.mdx b/src/pages/selfhosted/selfhosted-quickstart.mdx
index 5d50dfe4..a3b34215 100644
--- a/src/pages/selfhosted/selfhosted-quickstart.mdx
+++ b/src/pages/selfhosted/selfhosted-quickstart.mdx
@@ -126,6 +126,51 @@ The `/setup` page is only accessible when no users exist. After creating the fir
For automated deployments, you can create the first owner user through the setup API and optionally receive a Personal Access Token for bootstrapping resources. See [Automated setup with a Personal Access Token](/selfhosted/automated-setup).
+## Unattended installation
+
+When you install from cloud-init, a CI pipeline, or a Terraform provisioner, nobody is there to answer the script's questions. Answer them in advance with environment variables instead, and the script installs without prompting.
+
+For each question, the script uses the matching environment variable if it is set and not empty. An empty variable counts as unset: the script asks on the terminal, or, when there is no terminal, uses the default, or stops if the answer is required. So with no terminal at all, the script runs unattended on its own. Set `NETBIRD_NON_INTERACTIVE=true` if your automation attaches a terminal that nobody is watching.
+
+This installs the setup from the steps above (built-in Traefik with a Let's Encrypt certificate) with no prompts:
+
+```bash
+export NETBIRD_DOMAIN=netbird.my-domain.com
+export NETBIRD_LETSENCRYPT_EMAIL=admin@my-domain.com
+export NETBIRD_NON_INTERACTIVE=true
+curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
+```
+
+
+The variables must be set for `bash`, not `curl`. Export them first, as above, or set them after the pipe: `curl -fsSL | NETBIRD_DOMAIN=netbird.my-domain.com bash`. If you set them before `curl`, only `curl` sees them, and the script stops with `NETBIRD_DOMAIN is required for a non-interactive install.`
+
+
+| Variable | Default | Used with | What it sets |
+|----------|---------|-----------|--------------|
+| `NETBIRD_DOMAIN` | Required | Every option | The domain of your NetBird server, for example `netbird.my-domain.com` |
+| `NETBIRD_LETSENCRYPT_EMAIL` | Required | Option `0` | The email address for Let's Encrypt certificate notifications |
+| `NETBIRD_REVERSE_PROXY_TYPE` | `0` | Every option | The reverse proxy option, `0` to `5`, from [Reverse Proxy Selection](#reverse-proxy-selection) |
+| `NETBIRD_ENABLE_PROXY` | `false` | Option `0` | Whether to enable the [NetBird Proxy service](#enable-the-net-bird-proxy-service) |
+| `NETBIRD_ENABLE_CROWDSEC` | `false` | Option `0`, proxy enabled | Whether to add [CrowdSec IP reputation](#crowd-sec-ip-reputation-optional) |
+| `NETBIRD_TRAEFIK_EXTERNAL_NETWORK` | Empty | Option `1` | The Docker network of your existing Traefik |
+| `NETBIRD_TRAEFIK_ENTRYPOINT` | `websecure` | Option `1` | The entrypoint of your existing Traefik |
+| `NETBIRD_TRAEFIK_CERTRESOLVER` | Empty | Option `1` | The certificate resolver of your existing Traefik |
+| `NETBIRD_BIND_LOCALHOST_ONLY` | `true` | Options `2` to `5` | Whether NetBird's HTTP ports listen on `127.0.0.1` only |
+| `NETBIRD_EXTERNAL_PROXY_NETWORK` | Empty | Options `2` to `4` | The Docker network your reverse proxy runs on |
+| `NETBIRD_TRUSTED_PEERS` | The built-in Traefik's address for option `0`, empty otherwise | Every option | The address your reverse proxy reaches the NetBird server from, for example `172.20.0.5/32` |
+| `NETBIRD_NON_INTERACTIVE` | Not set | Every option | `true` never prompts, even when a terminal is available |
+
+The comment block at the top of [`getting-started.sh`](https://github.com/netbirdio/netbird/blob/main/infrastructure_files/getting-started.sh) is the authoritative list. It also has `NETBIRD_AGENT_NETWORK`, which installs the [Agent Network](/agent-network/how-it-works) preset instead of this setup. The variables need NetBird v0.77.1 or later, and `NETBIRD_TRUSTED_PEERS` needs v0.79.0 or later.
+
+Before you automate the install, know that:
+
+- **An invalid domain stops the script.** Unattended, a domain with a scheme (`https://`) or a port, a bare IP address, or the placeholder `netbird.example.com` ends the run instead of asking for another. A domain that does not resolve yet only prints a warning, but certificate issuance and client connections fail until it does, so create the DNS record first.
+- **Set `NETBIRD_TRUSTED_PEERS` if you use your own reverse proxy (options `1` to `5`).** Without it, the NetBird server trusts forwarded client IP headers from any source, not only from your proxy. The script only prints a warning, which is easy to miss in an unattended run.
+- **The script does not wait for your reverse proxy.** Interactively, options `2`, `4` and `5` pause until you confirm that your proxy is configured. Unattended, the script starts the containers straight away, and you configure the proxy afterwards.
+- **A rerun never overwrites an installation.** If the directory already has a generated `config.yaml`, the script stops and lists the files to remove. Run it in a fresh directory.
+
+Like the interactive install, an unattended install creates no users. Create the first user in the browser, as described in [Initial setup](#initial-setup-onboarding), or from your automation with the [setup API](/selfhosted/automated-setup).
+
## Add More Users
NetBird includes built-in local user management powered by an embedded Dex server, allowing you to create and manage users directly from the Dashboard without requiring an external identity provider. You can also add external identity providers for SSO authentication alongside local users.
diff --git a/src/pages/selfhosted/troubleshooting.mdx b/src/pages/selfhosted/troubleshooting.mdx
index 102b4cdd..9b999102 100644
--- a/src/pages/selfhosted/troubleshooting.mdx
+++ b/src/pages/selfhosted/troubleshooting.mdx
@@ -41,11 +41,12 @@ This page helps with issues when self-hosting NetBird. Collect the diagnostics b
@@ -82,7 +83,7 @@ This page helps with issues when self-hosting NetBird. Collect the diagnostics b
title: "Connectivity",
href: "/selfhosted/troubleshooting/connectivity",
icon: "firewall",
- description: "Testing the TURN server, peer-to-peer failures, and an unreachable Management service.",
+ description: "Checking the relay, peer-to-peer failures, and an unreachable Management service.",
},
{
title: "Database",
diff --git a/src/pages/selfhosted/troubleshooting/certificates.mdx b/src/pages/selfhosted/troubleshooting/certificates.mdx
index 6d7bc70e..e061c1ab 100644
--- a/src/pages/selfhosted/troubleshooting/certificates.mdx
+++ b/src/pages/selfhosted/troubleshooting/certificates.mdx
@@ -25,7 +25,7 @@ Deployments created before NetBird switched to Traefik may still run Caddy, whic
**Likely causes and fixes** (most common first):
-1. **Let's Encrypt is still enabled, so two components fight over TLS.** Set `NETBIRD_DISABLE_LETSENCRYPT=true` so NetBird stops managing certificates and leaves termination to your proxy.
+1. **NetBird is also terminating TLS, so two components fight over it.** Installs from `getting-started.sh` with options 1 to 5 don't do this: netbird-server serves plain HTTP behind your proxy. If you wrote `config.yaml` yourself, leave `server.tls.certFile` empty and `server.tls.letsencrypt.enabled` set to `false`. On older deployments installed with `configure.sh`, set `NETBIRD_DISABLE_LETSENCRYPT=true` in `setup.env`.
2. **Forwarded headers are missing.** Set `X-Forwarded-For` and `X-Forwarded-Proto` on the proxy so NetBird sees the original scheme and client.
3. **gRPC fails without HTTP/2.** The Management gRPC endpoints need HTTP/2; enable it on the proxy.
diff --git a/src/pages/selfhosted/troubleshooting/connectivity.mdx b/src/pages/selfhosted/troubleshooting/connectivity.mdx
index d471b3ae..00c56d0f 100644
--- a/src/pages/selfhosted/troubleshooting/connectivity.mdx
+++ b/src/pages/selfhosted/troubleshooting/connectivity.mdx
@@ -1,15 +1,29 @@
export const description =
- "Self-hosted NetBird connectivity troubleshooting: testing the TURN server, peer-to-peer connection failures, and an unreachable Management service."
+ "Self-hosted NetBird connectivity troubleshooting: checking the relay, peer-to-peer connection failures, and an unreachable Management service."
# Connectivity issues
Peer connectivity and relay problems on a self-hosted deployment. For other areas, start from [Troubleshooting](/selfhosted/troubleshooting).
+## Debugging relay connections
+
+When peers can't establish a direct connection, they fall back to the NetBird relay. In the combined setup, netbird-server runs the relay itself and serves it at `/relay` on TCP/443 through your reverse proxy. It also runs STUN on UDP/3478. There is no TURN server.
+
+To check the relay, run `netbird status -d` on a client and read the `Relays:` list. A working setup lists your STUN server and relay as available:
+
+```text
+Relays:
+ [stun:netbird.example.com:3478] is Available
+ [rels://netbird.example.com:443] is Available via ws
+```
+
+If it reads `Unavailable, reason: ...`, the reason names the failure. The most common cause is a reverse proxy that doesn't route `/relay` to netbird-server or doesn't allow the WebSocket upgrade. Compare your proxy with the routes in [External Reverse Proxy Setup](/selfhosted/external-reverse-proxy).
+
## Debugging TURN connections
-When peers can't establish a direct connection, they fall back to the TURN (relay) server. If the connection still fails with relay, confirm your TURN configuration is correct and reachable.
+This section applies to older multi-container deployments that run Coturn, configured in the `TURNConfig` section of `management.json`. The combined setup has no TURN server; use [Debugging relay connections](#debugging-relay-connections) instead.
-To test it, open the [Trickle ICE test tool](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice). In the **ICE servers** box, remove the default server and add your TURN server. Replace netbird.DOMAIN.com and PASSWORD with your STUN/TURN details from your configuration (`config.yaml` for the combined setup, or the `TURNConfig` section in `management.json` for older multi-container setups), then click Add server.
+To test it, open the [Trickle ICE test tool](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice). In the **ICE servers** box, remove the default server and add your TURN server. Replace netbird.DOMAIN.com and PASSWORD with your STUN/TURN details from the `TURNConfig` section in `management.json`, then click Add server.
@@ -33,7 +47,7 @@ The candidate types are `host` (local address), `srflx` (STUN reflexive address)
**Likely causes and fixes** (most common first):
-1. **STUN/TURN is unreachable.** Confirm UDP/3478 (STUN/TURN) is open, then verify the relay path with the [TURN test above](#debugging-turn-connections).
+1. **STUN or the relay is unreachable.** Confirm UDP/3478 (STUN) and TCP/443 are open, then check the relay with [Debugging relay connections](#debugging-relay-connections).
2. **A host firewall is dropping traffic** on one or both peers. Check the local firewall rules on each peer.
3. **The peer itself is unhealthy.** Run `netbird status -d` on both peers and check the connection type and last handshake.
@@ -47,6 +61,6 @@ The candidate types are `host` (local address), `srflx` (STUN reflexive address)
1. **TCP/443 is blocked** between the client and the server. Confirm the port is open end to end.
2. **DNS doesn't resolve your domain** to the server's public IP. Check resolution from the client.
-3. **Management is down or erroring.** Review `docker compose logs management`, and test the endpoint directly: `curl -v https://YOUR_DOMAIN/api/health`.
+3. **Management is down or erroring.** Review `docker compose logs netbird-server` (`management` on installs from before v0.65.0), and test the endpoint through your proxy: `curl -s https://YOUR_DOMAIN/api/instance`.
-**Confirm**: `curl https://YOUR_DOMAIN/api/health` returns a healthy response, and `netbird status` shows `Management: Connected`.
+**Confirm**: `curl -s https://YOUR_DOMAIN/api/instance` returns JSON with a `setup_required` field, and `netbird status` shows `Management: Connected`.
diff --git a/src/pages/selfhosted/troubleshooting/dashboard.mdx b/src/pages/selfhosted/troubleshooting/dashboard.mdx
index 742cc144..14c01834 100644
--- a/src/pages/selfhosted/troubleshooting/dashboard.mdx
+++ b/src/pages/selfhosted/troubleshooting/dashboard.mdx
@@ -11,8 +11,8 @@ Problems with the self-hosted dashboard. For other areas, start from [Troublesho
**Likely causes and fixes** (most common first):
-1. **The dashboard can't reach the Management API.** Open the browser console (F12 → Console) and look for failed requests. Confirm the dashboard's configured API URL is correct and reachable from the browser.
-2. **CORS (Cross-Origin Resource Sharing) is blocking the API behind a custom reverse proxy.** Serve the API on the same origin as the dashboard, or set the correct CORS headers on your proxy.
+1. **The dashboard can't reach the Management API.** Open the browser console (F12 → Console) and look for failed requests. Confirm `NETBIRD_MGMT_API_ENDPOINT` in `dashboard.env` is your public NetBird URL and is reachable from the browser.
+2. **CORS (Cross-Origin Resource Sharing) errors behind a custom reverse proxy.** netbird-server already sends CORS headers, so don't add them on the proxy: duplicate `Access-Control-Allow-Origin` headers make browsers reject the response. CORS errors can also mean the proxy doesn't route `/api` and `/oauth2` to netbird-server, so the browser gets a response from something else.
3. **A stale cached bundle.** Hard-reload the page or clear the browser cache.
**Confirm**: Reload the dashboard. The login or peers view renders, and the console shows no failed API requests.
@@ -25,6 +25,6 @@ Problems with the self-hosted dashboard. For other areas, start from [Troublesho
1. **An expired or invalid token.** Re-authenticate. For direct API access, use a valid Personal Access Token (PAT).
2. **The user lacks permission** for the action. Check the user's role in the dashboard.
-3. **Management can't validate the token.** Review `docker compose logs management` for token-validation errors.
+3. **Management can't validate the token.** Review `docker compose logs netbird-server` (`management` on installs from before v0.65.0) for token-validation errors.
**Confirm**: Re-run the action. It succeeds, and the Management logs show no auth errors.
diff --git a/src/pages/selfhosted/troubleshooting/database.mdx b/src/pages/selfhosted/troubleshooting/database.mdx
index bc223b22..04834c56 100644
--- a/src/pages/selfhosted/troubleshooting/database.mdx
+++ b/src/pages/selfhosted/troubleshooting/database.mdx
@@ -7,15 +7,15 @@ Database and migration problems on a self-hosted deployment. For other areas, st
## Management service won't start after upgrade
-**Symptom**: After an upgrade, the Management container fails to start or crash-loops.
+**Symptom**: After an upgrade, the netbird-server container (`management` on installs from before v0.65.0) fails to start or crash-loops.
**Likely causes and fixes** (most common first):
-1. **A failed schema migration.** Check `docker compose logs management` for migration errors. This is the usual cause and the logs name the failing step.
+1. **A failed schema migration.** Check `docker compose logs netbird-server` (`docker compose logs management` on installs from before v0.65.0) for migration errors. This is the usual cause and the logs name the failing step.
2. **A skipped intermediate version.** Major version jumps may require stepping through intermediate releases. Follow the [upgrade path](/selfhosted/maintenance/upgrade) for your version.
3. **A corrupt or partially-migrated database.** If the migration can't be completed, restore from a pre-upgrade backup and retry the upgrade.
-**Confirm**: `docker compose ps` shows `management` as `Up`, and its logs end with the service listening rather than a migration error.
+**Confirm**: `docker compose ps` shows `netbird-server` (or `management`) as `Up`, and its logs end with the service listening rather than a migration error.
## Data corruption after power loss
@@ -23,8 +23,15 @@ Database and migration problems on a self-hosted deployment. For other areas, st
**Likely causes and fixes** (most common first):
-1. **A stale lock file.** Check for and remove a leftover database lock if the process that held it is gone.
-2. **An interrupted write.** Review all service logs to find which store is failing. For SQLite, run an integrity check on the database file.
-3. **An unrecoverable file.** If the store can't be repaired, restore from the most recent backup.
+1. **An interrupted write.** Review all service logs to find which store is failing. With the default SQLite store, the files are `store.db`, `events.db`, and `idp.db` in the `netbird_data` volume (`/var/lib/netbird` inside the container). The netbird-server image doesn't include `sqlite3`, so stop the stack with `docker compose down` and check the files from a throwaway container. Find the volume name with `docker volume ls` (it ends in `netbird_data`), then run:
+
+ ```bash
+ docker run --rm -v VOLUME_NAME:/data:ro alpine sh -c \
+ "apk add -q sqlite && for f in store.db events.db idp.db; do echo \$f; if [ -f /data/\$f ]; then cp /data/\$f* /tmp/ && sqlite3 /tmp/\$f 'PRAGMA integrity_check;'; else echo MISSING; fi; done"
+ ```
+
+ A healthy file prints `ok`. The check runs on copies inside the throwaway container, so the volume isn't changed. If the interrupted write left a `-journal` file next to a store, the check applies it to the copy, the same way netbird-server does when it starts. `MISSING` means that store doesn't exist in the volume. Start the stack again with `docker compose up -d` when you're done.
+
+2. **An unrecoverable file.** If the store can't be repaired, restore from the most recent backup.
**Confirm**: All services report `Up` in `docker compose ps`, and peers reconnect with `netbird status` showing `Management: Connected`.
diff --git a/src/pages/selfhosted/troubleshooting/identity-provider.mdx b/src/pages/selfhosted/troubleshooting/identity-provider.mdx
index efe266e7..99f39dc3 100644
--- a/src/pages/selfhosted/troubleshooting/identity-provider.mdx
+++ b/src/pages/selfhosted/troubleshooting/identity-provider.mdx
@@ -13,7 +13,7 @@ Problems with the embedded identity provider (IdP) and SSO (single sign-on) on a
1. **A user already exists.** `/setup` is only available when no users exist. If you've already created one, use the regular login page.
2. **The embedded IdP is disabled.** Confirm it is enabled in your configuration.
-3. **Management isn't running.** Check `docker compose logs management`.
+3. **Management isn't running.** Check `docker compose logs netbird-server` (`management` on installs from before v0.65.0).
**Confirm**: `/setup` loads (first run), or the regular login page loads (setup already done).
@@ -29,9 +29,22 @@ Problems with the embedded identity provider (IdP) and SSO (single sign-on) on a
**Symptom**: You created a user but the password doesn't work.
-**Cause**: Passwords are shown only once at creation. If it wasn't saved, it can't be recovered.
+**Cause**: Passwords are shown only once at creation. If it wasn't saved, it can't be recovered, but you can set a new one.
-**Fix**: Recreate the user (delete and re-add, or create a new one with a new password via the API). For the owner account with no other admins, you may need to reset the database.
+**Fix**: Set a new password with the admin CLI (NetBird v0.75.0 and later). This works for the owner account too:
+
+```bash
+read -rsp 'New password: ' NB_PASSWORD; echo
+printf '%s\n' "$NB_PASSWORD" | docker exec -i netbird-server \
+ /go/bin/netbird-server --config /etc/netbird/config.yaml admin user change-password \
+ --email user@example.com \
+ --password-file -
+unset NB_PASSWORD
+```
+
+The password is read from a silent prompt, so it doesn't end up in your shell history.
+
+See [Change a local user's password](/selfhosted/maintenance/admin-cli#change-a-local-users-password) for the password rules and other options.
**Confirm**: You can log in with the new credentials.
@@ -43,7 +56,7 @@ Problems with the embedded identity provider (IdP) and SSO (single sign-on) on a
1. **The connector wasn't saved.** Check **Settings → Identity Providers**.
2. **The redirect URI is wrong.** Ensure the IdP application has the exact redirect URI shown by NetBird.
-3. **A configuration error.** Review `docker compose logs management` for connector errors.
+3. **A configuration error.** Review `docker compose logs netbird-server` for connector errors.
**Confirm**: The SSO button appears on the login page and starts the IdP flow.
@@ -61,9 +74,9 @@ Problems with the embedded identity provider (IdP) and SSO (single sign-on) on a
**Symptom**: The Identity Providers tab is missing from Settings.
-**Cause**: The tab only appears when the embedded IdP is enabled.
+**Cause**: The tab only appears when the embedded IdP is enabled and your role is allowed to read identity providers.
-**Fix**: Check your deployment: quickstart enables it by default, the combined setup always has it on, and older multi-container deployments need `EmbeddedIdP.Enabled` set to `true` in `management.json`.
+**Fix**: Check your deployment: quickstart enables it by default, the combined setup always has it on, and older multi-container deployments need `EmbeddedIdP.Enabled` set to `true` in `management.json`. If the embedded IdP is on and the tab is still missing, sign in with a role that can manage identity providers, or ask an administrator to check your role's permissions.
**Confirm**: The Identity Providers tab appears under Settings.
diff --git a/src/pages/selfhosted/troubleshooting/installation.mdx b/src/pages/selfhosted/troubleshooting/installation.mdx
index 96176560..1a6259e9 100644
--- a/src/pages/selfhosted/troubleshooting/installation.mdx
+++ b/src/pages/selfhosted/troubleshooting/installation.mdx
@@ -9,7 +9,14 @@ Problems running the self-hosted `getting-started.sh` script. For other areas, s
**Symptom**: The `getting-started.sh` script stays on the wait line for several minutes, even though netbird-server looks healthy in `docker ps`.
-The wait check probes the OIDC endpoint through your reverse proxy, so a healthy server alone isn't enough. Something on the path from the public internet to netbird-server is broken. Check these likely causes in order, most common first:
+What the wait checks depends on the reverse proxy option you picked:
+
+- **Options 0 and 1 (Traefik)**: the script probes the OIDC endpoint at your public hostname through your reverse proxy, so a healthy server alone isn't enough. Something on the path to netbird-server is broken. The probe runs from the installation host itself, so first run the `curl` in step 5 below from both that host and a machine outside your network. If only the host fails, the host can't reach its own public address (some NAT setups don't support this), and external clients may be fine. Otherwise, work through the causes below.
+- **Options 2 to 5 (Nginx, Nginx Proxy Manager, Caddy, other)**: the script probes netbird-server directly on `http://127.0.0.1:8081`, with no proxy in the path. A hang here means netbird-server itself isn't healthy, so start with `docker compose logs netbird-server`.
+
+After about two minutes of waiting, the script prints `Taking too long. Checking logs...` followed by the last 20 log lines from netbird-server, and from Traefik when the script can find its container. Read that output first; it often names the problem.
+
+Check these likely causes in order, most common first:
**1. DNS doesn't point at the host**
@@ -27,7 +34,7 @@ For options 0 (built-in Traefik) and 1 (existing Traefik), check that a real cer
curl -vI https://netbird.example.com 2>&1 | grep -E "subject|issuer"
```
-A self-signed or default cert means ACME hasn't completed. Check Traefik logs for ACME errors. Common causes: port 80 not reachable from the internet, DNS not propagated yet, or a Let's Encrypt rate limit.
+A self-signed or default cert means ACME hasn't completed. Check Traefik logs for ACME errors. Common causes: TCP/443 not reachable from the internet, DNS not propagated yet, or a Let's Encrypt rate limit. The built-in Traefik (option 0) validates with the TLS-ALPN-01 challenge on port 443, so port 80 doesn't affect issuance. With option 1, the challenge depends on your Traefik's certificate resolver.
**3. Traefik can't read the Docker socket (option 1 only)**
@@ -51,7 +58,7 @@ curl -k https://netbird.example.com/oauth2/.well-known/openid-configuration
A valid response is JSON containing `"issuer"`. Anything else points to where to dig: 502 means Traefik can't reach netbird-server, 404 means the labels aren't matched, connection refused means you're not hitting Traefik at all.
-**Confirm**: The probe succeeds and the script continues on its own. You can leave it waiting while you debug. To start over instead, stop it with Ctrl+C, run `docker compose down -v`, fix the issue, and re-run.
+**Confirm**: The probe succeeds and the script continues on its own. You can leave it waiting while you debug. To start over instead, stop it with Ctrl+C, remove the generated files as shown in [Script fails on existing installation check](#script-fails-on-existing-installation-check), fix the issue, and re-run. `docker compose down -v` on its own leaves `config.yaml` behind, and the script refuses to run while that file exists.
## Script fails on existing installation check
@@ -66,6 +73,7 @@ docker compose down --volumes
rm -f docker-compose.yml dashboard.env config.yaml proxy.env \
traefik-dynamic.yaml nginx-netbird.conf caddyfile-netbird.txt \
npm-advanced-config.txt
+rm -rf crowdsec/
```
**Confirm**: Re-run the script; it proceeds past the existing-installation check and starts provisioning.
diff --git a/src/pages/use-cases/deployment/mdm-rollout.mdx b/src/pages/use-cases/deployment/mdm-rollout.mdx
new file mode 100644
index 00000000..15a7afe6
--- /dev/null
+++ b/src/pages/use-cases/deployment/mdm-rollout.mdx
@@ -0,0 +1,281 @@
+import { Note, Warning } from "@/components/mdx";
+import { Tiles } from "@/components/Tiles";
+
+export const description =
+ "Roll NetBird out to hundreds of company laptops with your MDM: install the client, have users sign in with SSO, pin the client's settings so they cannot drift, and keep the fleet updated. Includes an end-to-end Microsoft Intune example for Windows and macOS.";
+
+# Roll Out NetBird Across Your Organization with MDM
+
+Installing NetBird on your own laptop takes a minute. Installing it on 500 company laptops, making sure every one of them connects to the right server as the right user, and keeping their settings the way your security team signed off on, is a different job.
+
+This guide shows how to do that with the MDM you already use, and walks through a complete rollout with Microsoft Intune.
+
+
+**TL;DR**: a fleet rollout is four jobs with one owner each. Your MDM **installs** the client and **pins its settings**, users **sign in with SSO** once, and you pick **one owner for updates**. Start with a pilot group, then widen. Jump to the [Intune example](#example-roll-out-with-microsoft-intune) if you are ready to build.
+
+
+## One device, by hand
+
+On a single machine:
+
+1. Download the installer: EXE or MSI on [Windows](/get-started/install/windows), PKG on [macOS](/get-started/install/macos).
+2. Run it with administrator rights.
+3. Open NetBird and choose the management server: NetBird Cloud, or self-hosted.
+4. Click **Connect** and sign in through the browser with your identity provider (IdP). The device joins as a peer under your user.
+
+That is enough for one person. Afterward the device's owner still controls the server URL, profiles, SSH, and every setting your security posture depends on. On one machine, nobody minds. On a fleet, each of those is a support ticket or a gap nobody notices.
+
+## Why it does not scale
+
+Repeat those four steps across an organization and each one turns into its own problem:
+
+- **Installing needs administrator rights** most users do not have (and should not).
+- **Users get the server wrong** and land in an account that is not yours.
+- **Setup keys break identity.** A peer enrolled with a setup key has no user behind it, so user-based policies, login expiration, and offboarding do not apply. Setup keys are for servers, not laptops: see [Bootstrap peers via config file](/manage/peers/bootstrap-via-config-file).
+- **Settings and versions drift** with no owner to pin them.
+- **Unmanaged devices look the same as managed ones** once someone signs in with valid credentials.
+
+## The rollout model
+
+| Job | Owner | What it uses |
+| --- | --- | --- |
+| **Install** the client | Your MDM | The standard installer, with no arguments: MSI on Windows, PKG on macOS |
+| **Enroll** the device | The user, once | SSO on first launch. The peer joins under the user's identity; [IdP group sync](/manage/team/idp-sync) places it |
+| **Enforce** the settings | Your MDM | An [MDM policy](/client/mdm-integration) that pins the management server and locks the settings you choose |
+| **Update** the client | NetBird **or** your MDM, never both | [Automatic Updates](/manage/peers/auto-update), or a new installer version in your MDM |
+
+Two properties make this split work:
+
+- **The installer carries no configuration; the policy carries all of it.** Same MSI or PKG everywhere. Change the policy anytime and the client applies it within a minute, with no reinstall.
+- **The policy removes the hard step for users.** When it pins the management server, the app skips the server question. The user opens NetBird, clicks **Connect**, and signs in with the account they already use.
+
+Optional jobs on the same tools:
+
+- **Gate access on compliance** with your MDM's [NetBird integration](/manage/access-control/endpoint-detection-and-response). See [Step 7](#step-7-optional-only-let-compliant-devices-in).
+- **Enroll devices with no user** (kiosks, meeting-room PCs, servers) with a [setup key](/manage/peers/register-machines-using-setup-keys). Keep them in their own group with their own policy.
+
+
+**Skip this guide when it does not match the job.** A handful of laptops is faster by hand. A Linux-only fleet has no MDM policy channel today: use [service-install flags or a config file](/client/mdm-integration#when-not-to-use-mdm-policy). Headless devices belong on setup keys, not this SSO path.
+
+
+## Supported MDMs
+
+NetBird uses three MDM capabilities and adds no agent of its own: app deployment, a managed-configuration channel (`HKLM\Software\Policies` on Windows, managed preferences on macOS), and an optional compliance signal.
+
+Any MDM that can install a package and write to those channels works. These have dedicated guides:
+
+| MDM | Install the client | Enforce settings |
+| --- | --- | --- |
+| [Microsoft Intune](https://learn.microsoft.com/en-us/intune/) | [Deploy with Intune](/manage/peers/mdm-deployment/intune-netbird-integration) | [Windows](/manage/peers/mdm-deployment/windows-mdm-policy), [macOS](/manage/peers/mdm-deployment/macos-mdm-policy#deliver-it-with-your-mdm) |
+| [Jamf Pro](https://www.jamf.com/products/jamf-pro/) | [Deploy with Jamf Pro](/manage/peers/mdm-deployment/jamf-pro-netbird-integration) | [macOS](/manage/peers/mdm-deployment/macos-mdm-policy#deliver-it-with-your-mdm) |
+| [Kandji](https://www.kandji.io/) | [Deploy with Kandji](/manage/peers/mdm-deployment/kandji-netbird-integration) | [macOS](/manage/peers/mdm-deployment/macos-mdm-policy#deliver-it-with-your-mdm) |
+| Group Policy (Active Directory) | [Deploy with Group Policy](/manage/peers/mdm-deployment/windows-gpo-deployment) | [Windows](/manage/peers/mdm-deployment/windows-mdm-policy#group-policy) |
+| [JumpCloud](https://jumpcloud.com/) | Upload the MSI or PKG as a software package | [Windows](/manage/peers/mdm-deployment/windows-mdm-policy#jump-cloud), [macOS](/manage/peers/mdm-deployment/macos-mdm-policy#deliver-it-with-your-mdm) |
+| Mosyle, Workspace ONE, and others | Upload the MSI or PKG as a software package | [Windows](/manage/peers/mdm-deployment/windows-mdm-policy#registry-reference), [macOS](/manage/peers/mdm-deployment/macos-mdm-policy#deliver-it-with-your-mdm) |
+
+This guide covers Windows and macOS. Linux has no MDM channel in NetBird today: install with your configuration management tool and set the same options with [service-install flags or a config file](/client/mdm-integration#when-not-to-use-mdm-policy).
+
+## Example: roll out with Microsoft Intune
+
+This example rolls NetBird out to Windows and macOS laptops at a company that uses Microsoft Entra ID for identity and Intune for device management. Users sign in with their Entra ID accounts. Laptops get the [user device policy](/client/mdm-integration#recommended-policies) recommended for end-user machines.
+
+The examples use a self-hosted management server at `https://netbird.example.com:443`. On NetBird Cloud, use `https://api.netbird.io:443` instead.
+
+### Before you start
+
+- NetBird SSO with Microsoft Entra ID. On NetBird Cloud, [Microsoft sign-in](/manage/team/single-sign-on#google-microsoft-and-git-hub) works with no extra setup; for self-hosted, see [Microsoft Entra ID](/selfhosted/identity-providers/managed/microsoft-entra-id).
+- Recommended: [Entra ID group sync](/manage/team/idp-sync/microsoft-entra-id-sync), so each new peer lands in its user's groups and your [access policies](/manage/access-control) apply from the first connection.
+- An Intune admin with at least the **Policy and Profile Manager** role, and Windows and macOS devices enrolled in Intune.
+- Two Entra ID **device** groups: `NetBird Pilot` (a handful of IT-owned laptops) and `NetBird Laptops` (every end-user laptop). Keep routing peers and servers out of both: the policy below stops a device from routing other peers' traffic.
+- The installers: the [Windows MSI](https://pkgs.netbird.io/windows/msi/x64), and the macOS PKG for [Apple Silicon](https://pkgs.netbird.io/macos/arm64) and [Intel](https://pkgs.netbird.io/macos/amd64).
+- The policy templates: netbird.admx and netbird.adml for Windows, and netbird-macos.mobileconfig for macOS.
+
+
+**Find laptops where users already installed NetBird themselves.** On Windows, the EXE and the MSI do not detect each other: pushing the MSI onto a laptop that has the EXE produces two overlapping installations instead of an upgrade. Uninstall the EXE first, or deploy the EXE with Intune instead. Either way, the peer's registration in `C:\ProgramData\Netbird` survives the uninstall, so the user does not have to enroll again.
+
+
+### Step 1: Define the policy
+
+Decide what to pin before you deploy anything. For end-user laptops, start from four keys with your server URL (for example `https://netbird.example.com:443`) as the management URL:
+
+| Key | Value | Why |
+| --- | --- | --- |
+| `managementURL` | Your server URL | Every laptop talks to your server; the app skips the server question on first launch |
+| `disableUpdateSettings` | `true` | Users can connect, disconnect, and sign in, but cannot change settings |
+| `disableProfiles` | `true` | Users cannot add a second profile (for example a personal NetBird account) |
+| `disableServerRoutes` | `true` | A laptop wrongly assigned as a routing peer does not carry other peers' traffic |
+
+Leave every other key out. A key in the policy is pinned even when its value is `false`; a key you leave out stays the user's to change. To adapt the policy, see [What you can achieve](/client/mdm-integration#what-you-can-achieve).
+
+
+**On NetBird Cloud, `managementURL` pins the server, not the account.** Every Cloud account shares `https://api.netbird.io:443`, so this key stops a user from pointing the client at another server, but not from signing out and into a different Cloud account. `disableProfiles` still blocks a second profile on the device. A peer that leaves your account loses access because your policies no longer apply to it.
+
+
+### Step 2: Deliver the policy
+
+Create the policy first and assign it **together with, or before,** the app. The desktop app decides whether to ask for a server when it opens for the first time; if the policy has not arrived by then, the user sees the question you meant to remove.
+
+**Windows**: an Imported Administrative templates profile.
+
+1. Import `netbird.admx` and `netbird.adml` once per tenant: **Devices → Manage devices → Configuration → Import ADMX → Import**.
+2. Create the profile: **Devices → Manage devices → Configuration → Create → New policy**, platform **Windows 10 and later**, profile type **Templates → Imported Administrative templates (Preview)**. Name it `NetBird: user laptops`.
+3. Under **NetBird**, set **Management URL** to **Enabled** with your URL, and **Disable update settings**, **Disable profiles**, and **Disable server routes** to **Enabled**. Leave everything else **Not configured**: **Disabled** pins the setting to `false`.
+4. Assign the profile to the `NetBird Pilot` device group.
+
+[Enforce NetBird Settings on Windows](/manage/peers/mdm-deployment/windows-mdm-policy#enforce-settings-with-intune) has every step in detail, plus an OMA-URI alternative for tenants that cannot import templates.
+
+**macOS**: a custom configuration profile.
+
+1. Edit `netbird-macos.mobileconfig`. Inside the `mcx_preference_settings` dictionary, keep only the four keys, and replace each `PayloadUUID` with a fresh value from `uuidgen`:
+
+ ```xml
+ managementURL
+ https://netbird.example.com:443
+ disableUpdateSettings
+
+ disableProfiles
+
+ disableServerRoutes
+
+ ```
+
+ Write booleans as `` or ``, never as ``: macOS locks an integer boolean but never applies it.
+2. Create the profile: **Devices → Manage devices → Configuration → Create → New policy**, platform **macOS**, profile type **Templates → Custom**. Upload the file and name it `NetBird: user laptops`.
+3. Assign it to the `NetBird Pilot` device group.
+
+See [Enforce NetBird Settings on macOS](/manage/peers/mdm-deployment/macos-mdm-policy) for the template details and how to check the profile arrived.
+
+### Step 3: Deploy the client
+
+**Windows**: the MSI as a line-of-business app.
+
+1. **Apps → Windows → Create**, app type **Line-of-business app**, and upload the MSI.
+2. Set **App install context** to **Device**, and leave **Command-line arguments** empty: the policy carries the configuration.
+3. Set **Ignore app version** according to who owns updates, in [Step 5](#step-5-pick-one-owner-for-updates).
+4. Assign it as **Required** to the `NetBird Pilot` device group.
+
+
+
+
+
+**macOS**: the PKG as a macOS app.
+
+1. **Apps → macOS → Create**, app type **macOS app (PKG)**, and upload the PKG. Apple Silicon and Intel Macs need different packages: add each as its own app, and assign each to a group that holds only Macs of that type.
+2. The bundle ID is `io.netbird.client`. Set **Ignore app version** as shown in [Step 5](#step-5-pick-one-owner-for-updates).
+3. Assign it as **Required** to the `NetBird Pilot` device group.
+
+For a Win32 package with custom detection rules and supersedence, see [Deploy with Intune](/manage/peers/mdm-deployment/intune-netbird-integration#deploying-net-bird-as-a-win32-app).
+
+### Step 4: First sign-in
+
+Intune installs NetBird and starts its service. The device does not join your network until its user signs in, so tell users what to expect before the pilot starts:
+
+- **On macOS**, the installer opens the NetBird app for the user who is logged in.
+- **On Windows**, a silent install does not open the app. The user opens **NetBird** from the Start menu once; from then on it opens at every login.
+
+Because the policy pins the server, the app skips the server question. The user clicks **Connect**, signs in with their Entra ID account in the browser, and is connected. The new peer appears in the NetBird dashboard under that user's name and, with group sync, in that user's groups.
+
+
+
+
+
+A short message is enough:
+
+> NetBird is now installed on your laptop. Open NetBird, click Connect, and sign in with your work account. You only need to do this once.
+
+### Step 5: Pick one owner for updates
+
+The client can be updated by NetBird or by Intune. Choose one: when both try, they work against each other.
+
+| Owner | What to do |
+| --- | --- |
+| **NetBird** updates the client | Enable [Automatic Updates](/manage/peers/auto-update) under **Settings → Clients**, and **Force Automatic Updates** to install without prompting. Set **Ignore app version** to **Yes** on the Intune apps so Intune only checks that NetBird is installed. Otherwise Intune sees the self-updated version as a different app, tries to reinstall the older one, and the MSI refuses. |
+| **Intune** updates the client | Leave Automatic Updates disabled in NetBird, set **Ignore app version** to **No**, and upload each new MSI and PKG when you are ready to roll it out. |
+
+Letting NetBird update is less work and keeps clients close to your management server's version. Letting Intune do it gives you change windows and staged rings. To hold the fleet on a specific version with NetBird, set Automatic Updates to a **Custom Version**.
+
+### Step 6: Verify the pilot, then widen
+
+Check each layer, from Intune down to the device:
+
+1. **In Intune**, the app's **Device install status** and the profile's **Device status** show success for every pilot device.
+2. **On a Windows device**, from an elevated prompt:
+
+ ```shell
+ reg query HKLM\Software\Policies\NetBird
+ netbird debug config
+ netbird status
+ ```
+
+ `reg query` shows what Intune wrote. In `netbird debug config`, the `mDMManagedFields` array lists every key the client reads from the policy:
+
+ ```json
+ "mDMManagedFields": [
+ "disableProfiles",
+ "disableServerRoutes",
+ "disableUpdateSettings",
+ "managementURL"
+ ]
+ ```
+
+3. **On a macOS device**, run `netbird debug config` and `netbird status` the same way. The [macOS page](/manage/peers/mdm-deployment/macos-mdm-policy) shows how to check the profile itself.
+4. **In the NetBird dashboard**, each pilot device appears under **Peers** with its user's name and groups.
+5. **As a user**, open the app: the **Network**, **Security**, **SSH**, **Advanced**, and **Profiles** settings tabs are gone, and **Connect** still works.
+
+When the pilot looks right, add the `NetBird Laptops` group to the assignments of both apps and both profiles. Devices pick them up at their next Intune check-in. If something is off, see [Verifying enforcement](/client/mdm-integration#verifying-enforcement) and [Troubleshooting](/client/mdm-integration#troubleshooting).
+
+### Step 7 (optional): Only let compliant devices in
+
+With the client on every company laptop, you can make sure nothing else gets in. NetBird's [Intune integration](/manage/access-control/endpoint-detection-and-response/intune-mdm) checks each peer in the groups you select against Intune: a device that is not managed by Intune, or not compliant with your compliance policies, waits for approval and cannot reach anything. A personal laptop signed in with a valid account stays locked out.
+
+Connect Intune under **Integrations → EDR** in the NetBird dashboard and select the groups the check applies to, such as the groups synced from Entra ID for your employees. The integration covers Windows and macOS, and is available on the NetBird Cloud Business plan and with a self-hosted [Enterprise license](/selfhosted/enterprise).
+
+
+
+
+
+A device that fails the check shows **Approval required** in the peers list until Intune reports it as managed and compliant:
+
+
+
+
+
+## Rollout checklist
+
+- SSO works with your IdP, and groups sync into NetBird
+- Access policies use the synced groups
+- Laptops with a user-installed NetBird EXE are cleaned up, or you deploy the EXE
+- The policy holds only the keys you mean to pin
+- The policy is assigned together with, or before, the app
+- One owner for updates, with **Ignore app version** set to match
+- Users know to open NetBird, click **Connect**, and sign in once
+- The pilot group checks out in Intune, on the device, and in the dashboard
+- Routing peers and servers are outside the laptop groups
+- Optional: the Intune integration gates access on compliance
+
+
diff --git a/src/pages/use-cases/index.mdx b/src/pages/use-cases/index.mdx
index b699f80f..777e0172 100644
--- a/src/pages/use-cases/index.mdx
+++ b/src/pages/use-cases/index.mdx
@@ -48,6 +48,13 @@ You know what you want to achieve; this page finds the guide that gets you there
| Implement Zero Trust networking step by step | [Implement Zero Trust](/use-cases/security/implement-zero-trust) |
| Publish internal services without opening inbound ports | [Private Proxy Without Inbound Ports](/use-cases/security/private-no-inbound) |
+### Roll NetBird out to company devices
+
+| I want to... | Guide |
+| -------------------------------------------------------------- | ------------------------------------------------------ |
+| Install and configure NetBird on every company laptop with MDM | [Roll Out with MDM](/use-cases/deployment/mdm-rollout) |
+| Stop users changing NetBird's settings on managed devices | [MDM Integration](/client/mdm-integration) |
+
### Run NetBird at home
Homelabbers get a dedicated starting point covering NAS access, home automation, media servers, and connecting family networks: see the [Homelab guide](/use-cases/homelab). To run NetBird directly on your router, see the [MikroTik](/get-started/install/mikrotik), [OpenWrt](/get-started/install/openwrt), [pfSense](/get-started/install/pfsense), and [OPNsense](/get-started/install/opnsense) install guides.
diff --git a/src/pages/use-cases/remote-access/exit-nodes.mdx b/src/pages/use-cases/remote-access/exit-nodes.mdx
index 4aba92ee..a41f7b92 100644
--- a/src/pages/use-cases/remote-access/exit-nodes.mdx
+++ b/src/pages/use-cases/remote-access/exit-nodes.mdx
@@ -148,7 +148,7 @@ On an east-coast device, run `netbird status -d`: the peer carrying the `0.0.0.0
### What this does and does not do
-This is nearest-exit selection plus automatic failover, not load balancing: each device follows its own lowest-latency exit node, and traffic is not spread evenly across the exit nodes. See [Latency switching](/manage/networks/how-routing-peers-work#latency-switching-equal-metrics) for the selection mechanism.
+This is nearest-exit selection plus automatic failover, not load balancing: each device follows its own lowest-latency exit node, and traffic is not spread evenly across the exit nodes. See [Nearest routing peer](/manage/networks/how-routing-peers-work#nearest-routing-peer-equal-metrics) for the selection mechanism.
Latency-based selection is also not a guarantee of *which* exit node a given user lands on. If a rule must always hold, such as certain users always exiting in a specific country, use separate exit nodes with per-group distribution, or posture checks as in [Geo-Based Exit Node Routing](#geo-based-exit-node-routing) below. Make sure each device receives at most one exit node with [Auto Apply](#exit-node-selection-and-auto-apply) enabled: enable it on one exit node per distribution group, and keep those groups from overlapping — more than one auto-applied exit node on a device is the failure mode described in step 2 above. Offer any additional exit nodes with Auto Apply disabled; users can switch between exit nodes manually in the client either way.
@@ -214,7 +214,7 @@ Set up the exit node as described in the [configuration steps](#configuration-st
### 2. Lock network selection on the devices
-Deliver `disableNetworks: true` to the devices through your device management tooling: a registry policy or Intune profile on Windows, a configuration profile on macOS. See [MDM Integration](/client/mdm-integration) for the payload formats and delivery options per platform. The daemon re-reads the policy about once a minute, so it takes effect without restarting NetBird.
+Deliver `disableNetworks: true` to the devices through your device management tooling: a registry policy or Intune profile on Windows, a configuration profile on macOS. See [MDM Integration](/client/mdm-integration) for how the client applies a policy, and the [Windows](/manage/peers/mdm-deployment/windows-mdm-policy), [macOS](/manage/peers/mdm-deployment/macos-mdm-policy), and [iOS](/manage/peers/mdm-deployment/ios-mdm-policy) pages for the payload formats and delivery options. The daemon re-reads the policy about once a minute, so it takes effect without restarting NetBird.
On Linux devices and servers, where there is no MDM channel, set the equivalent flag when installing the service:
@@ -245,7 +245,7 @@ On a locked device, `netbird networks list` returns the "network selection is di
With Auto Apply and `disableNetworks` in place, every device in `remote-workers` routes its internet traffic through the exit node, and no user can select, deselect, or switch exit nodes from the device. Enforcement lives in the daemon, so the CLI and the UI are equally locked.
-It does not make the tunnel itself mandatory. A user can still disconnect NetBird entirely with `netbird down`, and a user with administrator rights on the device can stop or remove the service; NetBird has no always-on or kill-switch mode. Enforce that layer with the operating system: run devices with standard user accounts, manage the NetBird service through your MDM, and design your access policies so that disconnecting from NetBird costs the user access to company resources instead of freeing them from restrictions. The `disableUpdateSettings` and `disableProfiles` keys close the remaining side doors of reconfiguring the client or switching to an unmanaged profile; see [MDM Integration](/client/mdm-integration#policy-keys-reference).
+It does not make the tunnel itself mandatory. A user can still disconnect NetBird entirely with `netbird down`, and a user with administrator rights on the device can stop or remove the service; NetBird has no always-on or kill-switch mode. Enforce that layer with the operating system: run devices with standard user accounts, manage the NetBird service through your MDM, and design your access policies so that disconnecting from NetBird costs the user access to company resources instead of freeing them from restrictions. The `disableUpdateSettings` and `disableProfiles` keys close the remaining side doors of reconfiguring the client or switching to an unmanaged profile; see [Lock the client app](/client/mdm-integration#lock-the-client-app).
## Selective Internal Access Behind an Exit Node
diff --git a/src/pages/use-cases/security/implement-zero-trust.mdx b/src/pages/use-cases/security/implement-zero-trust.mdx
index 1b3d804c..cf79bf28 100644
--- a/src/pages/use-cases/security/implement-zero-trust.mdx
+++ b/src/pages/use-cases/security/implement-zero-trust.mdx
@@ -15,6 +15,10 @@ Before you start planning or changing policies, confirm the basics.
This guide uses features from both the **Team** and **Business** plans. Check that your deployment includes what you need.
+
+ Team and Business are NetBird Cloud plans. On a self-hosted deployment, IdP user and group provisioning, traffic events logging, and event streaming require an [Enterprise Commercial License](/selfhosted/enterprise). Audit event logging and posture checks are included in the open source Community Edition.
+
+
**Team plan**
- **[User and group provisioning from IdP (Identity Provider)](/manage/team/idp-sync)** for automatic group sync
@@ -511,7 +515,7 @@ A routing peer is a NetBird peer that:
- Is selected as a routing peer in a [Network](/manage/networks) or [Network Route](/manage/network-routes) configuration
-
+
### 5.2 Direction of traffic with routing peers
diff --git a/typography.js b/typography.js
index 5915d5a4..b5fa59c4 100644
--- a/typography.js
+++ b/typography.js
@@ -48,9 +48,13 @@ module.exports = ({ theme }) => ({
// Layout
'> *': {
- maxWidth: theme('maxWidth.2xl'),
- marginLeft: 10,
+ maxWidth: '100%',
+ marginLeft: 0,
marginRight: 'auto',
+ '@screen md': {
+ maxWidth: theme('maxWidth.2xl'),
+ marginLeft: 10,
+ },
'@screen lg': {
maxWidth: theme('maxWidth.3xl'),
marginLeft: 10,