Keep the Self-Hosted Dashboard and API Private

Updated

The quickstart makes every endpoint of your NetBird server reachable from the internet. NetBird clients need most of these endpoints, but some are only needed for administration. This page shows how to add an extra layer of security by making the admin endpoints reachable only from addresses you allowlist in your internal network, or over NetBird:

  • Client endpoints stay public: Management, Signal, /relay, /oauth2, STUN, and on Enterprise /flow.FlowService/. NetBird clients connect to these from anywhere.
  • Admin endpoints are reachable only from addresses you allowlist or over NetBird: the dashboard, the REST API (/api), /ws-proxy/ (the dashboard's browser SSH and RDP) and /management.ProxyService/ (where the NetBird Proxy, NetBird's own reverse proxy feature, connects).

This page is for a NetBird server on one host, installed with the quickstart with Traefik as its reverse proxy: you picked Traefik or Existing Traefik when the quickstart asked which reverse proxy to use. If you picked Nginx, Nginx Proxy Manager or External Caddy, the Traefik steps on this page do not apply. With a load balancer in front of that one host, also read the load balancer section. A highly available deployment, where Management and Signal run as several replicas, is not covered.

Running example: the server, netbird.example.com, has the public address 203.0.113.10 and the private address 10.0.2.10. The office LAN is 10.0.1.0/24, with the admins' workstations at 10.0.1.20 and 10.0.1.21. One admin, Dana, works from home with the NetBird client installed.

What must stay public

Path or portUsed forIf blocked from the internet
/flow.FlowService/ (Enterprise)Clients sending traffic eventsNewly connected clients cannot send traffic events
/management.ManagementService/ (gRPC)Every client's login and updatesNo client can log in, and connected clients lose their Management connection
/oauth2SSO logins through the embedded identity provider, also when it forwards to an external identity provider; the client itself requests its login code hereSSO login fails before a browser opens; peers that join with a setup key are unaffected
/relayConnections that cannot go directThose connections fail; direct connections are unaffected
/signalexchange.SignalExchange/ (gRPC)Setting up connections between peersnetbird up times out and no connection between peers forms
UDP 3478 (STUN)Peers learning their public address, for direct connectionsPeers behind different NATs connect through the relay instead of directly

This page keeps all of them public. Two you could block if you accept the cost: STUN, if relayed connections are acceptable, and /oauth2, if every client joins with a setup key.

To take /relay and STUN off this server, move them to external relay servers with STUN enabled; both guides at the end of this paragraph set them up that way. Setting relays in the server's config.yaml turns off its own relay and STUN server, so also list the external servers under stuns, or clients keep trying STUN on this server, where nothing answers any more. Keep /relay and UDP 3478 public until relays and stuns are both set. Each external relay server needs TCP and UDP on the port its relay listens on (443 in the guides; WebSocket over TCP, QUIC over UDP) and UDP 3478 (STUN) public. Community edition: Set Up External Relay Servers. Enterprise edition: External Relays on a Commercial License.

The quickstart's built-in Traefik obtains and renews its Let's Encrypt certificate over port 443 (the TLS-ALPN-01 challenge), so TCP 80 need not be public. With port 80 blocked, no client is affected; only the plain HTTP to HTTPS redirect stops working. With Existing Traefik, keep TCP 80 public if your certificate resolver uses the HTTP-01 challenge, or certificate issuance and renewal fail.

What can be internal-only

PathUsed forFrom the internet, once restricted
The dashboard (the web UI at https://netbird.example.com/ and its pages)Admins' browsers403 from any address not on the allowlist; admins elsewhere reach it over NetBird
/apiThe dashboard, scripts and other API toolsTools calling the API from the internet get 403, even with a valid access token
/management.ProxyService/The NetBird ProxyA NetBird Proxy on another host is refused unless its address is on the allowlist
/ws-proxy/The browser client behind the dashboard's browser SSH and RDPBrowser SSH and RDP work only for admins who can open the dashboard

The NetBird clients you install never use /ws-proxy/. Only the browser client does: a NetBird client the dashboard runs in the browser tab for browser SSH and RDP. The admin's browser downloads the browser client from pkgs.netbird.io by default, so it needs that egress. To serve the browser client from another location, set NETBIRD_WASM_PATH in the dashboard container's environment to that URL.

In the quickstart's layout, the NetBird Proxy connects to the server over the Docker network (http://netbird-server:80), not through Traefik, so a Proxy on the same host needs no allowlist entry. A NetBird Proxy on another host reaches the server through Traefik instead: put its address on the allowlist, or its connection is refused (PermissionDenied in its log).

Why the split happens in Traefik

The client endpoints Management, Signal, /relay and /oauth2, and the admin endpoints /api, /ws-proxy/ and /management.ProxyService/, all run in one netbird-server process, which has no setting to restrict an endpoint by source address. The dashboard runs in its own container, not in the same process as the API. Every endpoint except STUN (UDP 3478), the dashboard included, is reached on TCP 443, so a port firewall cannot separate them either; TCP 443 must stay open to everyone. Traefik receives every request for netbird.example.com on TCP 443 before netbird-server or the dashboard does, and it can match each request on both its path and its source address, so the split has to happen in Traefik.

When not to do this

Keep the dashboard public if admins must manage NetBird from devices that cannot run the NetBird client, or if you need the dashboard's browser SSH and RDP from anywhere: both stop working from the internet with this split. The same goes for scripts, CI pipelines and other tools that call the API from the internet: with a valid access token they still get 403, so run them from an allowlisted address or over NetBird.

Allowlist exact sources, not subnets

The allowlist (sourceRange in the Traefik setup below) decides who counts as internal. Every address on it reaches everything in What can be internal-only; the dashboard and the API still ask for a login. A host you allowlist for one path, such as an identity provider inside your network that pushes SCIM updates to /api, or a NetBird Proxy on another host, gets the same access as an admin workstation.

The office admins reach the server at its private address: their DNS answers netbird.example.com with 10.0.2.10, as in Hairpin NAT and Split DNS. Traefik then sees each workstation's own address, 10.0.1.20 or 10.0.1.21, and those exact addresses go on the allowlist. Without that DNS answer, a request from the office goes to the public address and can pass the site firewall's hairpin NAT, arrive with a public address as its source, and get 403.

Optional paths

Make these public only for the feature that needs them; each is a narrow exception to the internal /api and dashboard. The only other API paths that need no login are /api/instance and /api/setup, used during the first setup; they stay internal. The routers are in step 3 below.

  • The invite link for local users (users who sign in with a password at the embedded identity provider). If invitees accept from the internal network, keep this internal. Otherwise, the invite link opens the dashboard's invite page, /invite?token=… (invite tokens start with nbi_), and these must be public:

    • the two API paths the page calls, which need no login: /api/users/invites/nbi_… and /api/users/invites/nbi_…/accept (router public-invite-api);
    • the page and the files it loads: /invite, /_next/ and /assets/ (router public-invite-page, which also matches /invite.*).

    The rest of the dashboard and the API stay internal. The dashboard's static files become public. With the embedded identity provider, the quickstart leaves AUTH_CLIENT_SECRET empty. Run docker compose config dashboard: its output shows the AUTH_CLIENT_SECRET the dashboard container gets, also when the dashboard service sets it under environment: rather than in dashboard.env. If the output shows a non-empty AUTH_CLIENT_SECRET, do not make the invite link public: the dashboard writes the secret into its static files, which the invite link makes public, so anyone on the internet could read it.

  • SSO on services published with the NetBird Proxy. After a user signs in, the identity provider sends the browser to /api/reverse-proxy/callback. Make exactly that path public (router public-proxy-callback). Variants of it (a trailing slash, encoded or .. segments, different case) and the rest of /api stay 403. Password-protected services need nothing extra: the Proxy checks the password with the server over the Docker network.

  • SCIM from a cloud identity provider. SCIM lives under /api/scim/v2. A cloud identity provider needs that prefix public (router public-scim) and no allowlist entry: the SCIM token is its only check, and that token is refused (401) everywhere else in the API, so this does not open the admin API. An identity provider inside your network keeps SCIM internal instead: make it resolve netbird.example.com to 10.0.2.10, for the same reason as the office admins (a hosts entry is enough), and add its private address to sourceRange.

Set up the split in Traefik

The quickstart configures Traefik with Docker labels. When several routers match a request, Traefik uses the one with the highest priority. The split adds routers in a Traefik file provider and sets their priorities against the quickstart's:

RouterPriorityWhy
relay-exact-only260Above everything, to stop /relay… side paths
The optional public routers250Above private-api, to open single paths
private-api, private-proxy-service200Above the quickstart's NetBird routers (100)
The quickstart's NetBird routers100The public paths keep using them. Built-in Traefik: set by the quickstart. Existing Traefik: netbird-grpc and netbird-backend get it from the labels at the end of step 2
private-dashboard50Above the quickstart's dashboard (1), below the public paths
  1. Give Traefik a watched file-provider directory. In docker-compose.yml, add to the traefik service's command:

    - "--providers.file.directory=/etc/traefik/dynamic"
    - "--providers.file.watch=true"
    

    and to its volumes:

    - ./traefik-dynamic:/etc/traefik/dynamic:ro
    

    Traefik's file provider takes either a filename or a directory, not both. If you enabled the NetBird Proxy, your Traefik already loads --providers.file.filename=/etc/traefik/dynamic.yaml: replace that flag with the two above, and move the contents of dynamic.yaml (the pp-v2 serversTransport that the NetBird Proxy needs) into traefik-dynamic/proxy.yml.

  2. Create traefik-dynamic/private-admin.yml. Replace netbird.example.com with your name, and list in sourceRange exactly the addresses admins come from (see Allowlist exact sources):

    http:
      middlewares:
        admin-only:
          ipAllowList:
            sourceRange: ["10.0.1.20/32", "10.0.1.21/32"]
        deny-all:
          ipAllowList:
            sourceRange: ["192.0.2.1/32"]
      routers:
        private-api:
          rule: Host(`netbird.example.com`) && (PathPrefix(`/api`) || PathPrefix(`/ws-proxy/`))
          entryPoints: [websecure]
          priority: 200
          middlewares: [admin-only]
          service: netbird-server@docker
          tls: {certResolver: letsencrypt}
        private-proxy-service:
          rule: Host(`netbird.example.com`) && PathPrefix(`/management.ProxyService/`)
          entryPoints: [websecure]
          priority: 200
          middlewares: [admin-only]
          service: netbird-server-h2c@docker
          tls: {certResolver: letsencrypt}
        private-dashboard:
          rule: Host(`netbird.example.com`)
          entryPoints: [websecure]
          priority: 50
          middlewares: [admin-only]
          service: dashboard@docker  # Existing Traefik: netbird-dashboard@docker, see below
          tls: {certResolver: letsencrypt}
        relay-exact-only:
          rule: Host(`netbird.example.com`) && PathPrefix(`/relay`) && !Path(`/relay`)
          entryPoints: [websecure]
          priority: 260
          middlewares: [deny-all]
          service: netbird-server@docker
          tls: {certResolver: letsencrypt}
    

    deny-all allows only 192.0.2.1, a documentation address that never reaches you, so every request it guards gets 403. The relay-exact-only router closes a side door: the quickstart routes every path that starts with /relay to the server, and an encoded path such as /relay%2F..%2Fapi/users reaches the API handler, which answers with a redirect to /api/users. The redirect leaks nothing, since /api/users is 403 from the internet; the router stops such paths before they reach the API, and /relay itself keeps working.

    With Existing Traefik, replace websecure and letsencrypt with your own entrypoint and certificate resolver, here and in step 3; the netbird-server@docker and netbird-server-h2c@docker service names stay the same.

    In the service: line of private-dashboard, and of public-invite-page in step 3, put the <name> part of the dashboard container's traefik.http.services.<name>.loadbalancer.server.port label key in your docker-compose.yml, followed by @docker. For example, the v0.82.0 quickstart uses dashboard@docker with the built-in Traefik and netbird-dashboard@docker with Existing Traefik. With the wrong name, Traefik skips the router. If private-dashboard is skipped, the dashboard route from the quickstart's Docker labels keeps serving the dashboard to everyone, and the first check in Verify prints 200 instead of 403. If public-invite-page is skipped, invitees who open the invite link get 403.

    With Existing Traefik, pin the quickstart's netbird-grpc and netbird-backend routers to priority 100, whatever your hostname's length. They carry no priority label, so Traefik ranks each by the length of its rule, which grows with your hostname; from a hostname of 91 characters on, netbird-backend ties with or outranks private-api, and /api stays reachable from the internet. Pinning both keeps either from outranking the private routers. Add these labels under the netbird-server service's labels: in docker-compose.yml, then run docker compose up -d, which recreates the container:

    - traefik.http.routers.netbird-grpc.priority=100
    - traefik.http.routers.netbird-backend.priority=100
    
  3. For each optional path you need, add its router to the same file, under routers:. They take priority 250 and no middleware:

        public-invite-api:      # the invite link: both API paths
          rule: Host(`netbird.example.com`) && PathPrefix(`/api/users/invites/nbi_`)
          entryPoints: [websecure]
          priority: 250
          service: netbird-server@docker
          tls: {certResolver: letsencrypt}
        public-invite-page:     # the invite link: the page and its files
          rule: Host(`netbird.example.com`) && (Path(`/invite`) || PathPrefix(`/invite.`) || PathPrefix(`/_next/`) || PathPrefix(`/assets/`))
          entryPoints: [websecure]
          priority: 250
          service: dashboard@docker  # Existing Traefik: netbird-dashboard@docker, see step 2
          tls: {certResolver: letsencrypt}
        public-proxy-callback:  # SSO on NetBird Proxy services
          rule: Host(`netbird.example.com`) && Path(`/api/reverse-proxy/callback`)
          entryPoints: [websecure]
          priority: 250
          service: netbird-server@docker
          tls: {certResolver: letsencrypt}
        public-scim:            # SCIM from a cloud identity provider
          rule: Host(`netbird.example.com`) && PathPrefix(`/api/scim/v2`)
          entryPoints: [websecure]
          priority: 250
          service: netbird-server@docker
          tls: {certResolver: letsencrypt}
    
  4. Apply it: docker compose up -d traefik. Later edits to the file take effect without a restart.

Keep these changes in docker-compose.yml and its directory. migrate-to-enterprise.sh recreates the stack with docker compose up -d without -f, so (unless COMPOSE_FILE is set) it loads docker-compose.yml and the docker-compose.override.yml it generates, and a restriction that depends on a Compose file you pass with an extra -f is dropped. Check the split again after every upgrade or migration (see Verify).

If a load balancer sits in front of Traefik

The allowlist checks the address Traefik sees. Behind a load balancer, that is the balancer's address, not the client's: with the balancer's address on the allowlist, everyone gets in; without it, everyone gets 403, admins included. With a TCP (layer 4) balancer, turn on PROXY protocol on the balancer and let Traefik trust it, by adding to the traefik service's command:

- "--entrypoints.websecure.proxyProtocol.trustedIPs=10.0.2.5/32"

with your balancer's address instead of 10.0.2.5. Until Traefik has this flag, every connection the balancer sends with PROXY protocol fails: the client gets no HTTP response. With the flag, Traefik sees each client's own address and the allowlist works as on this page. Connections that reach Traefik directly, not through the balancer, keep working with the flag set.

Reach the private dashboard by name

If all your admins work from the internal network, skip this section.

Dana, at home, needs https://netbird.example.com to open the dashboard. Her browser resolves the name to the public address 203.0.113.10 and gets 403, as it should. The way to let her in changes no DNS:

  1. Pick a dedicated routing peer for admin access, inside your network, whose outbound address no other device shares. An office NAT address is shared by every device behind it, so give the routing peer an outbound address of its own first. It must also route nothing else (see Allowlist exact sources).
  2. Add a network route for the server's public address, 203.0.113.10/32, through that routing peer, with masquerade on, distributed to your admins' group.
  3. Add an access policy from the admins' group to a group that contains only that routing peer, on TCP 443. Without such a policy, the admins' devices do not receive the route.
  4. To find the address to allowlist, have Dana open the dashboard once (she still gets 403) and read the source address of that request in docker compose logs traefik; the quickstart's Traefik logs every request with its source. Because the route leads to the server's public address, the source is the routing peer's outbound address. Add it to sourceRange.

Dana's browser traffic to 203.0.113.10 now follows the route and reaches Traefik from the routing peer: the dashboard opens at once (200), and browser SSH works too. Her NetBird client's own management, signal and relay connections do not use the route and keep going to the public address; they reconnect when dropped and after a client restart. With NetBird down, her device goes over the internet again and gets 403.

Two DNS-based approaches look simpler. Do not use them for the server's name:

  • A record for the server's name in a NetBird custom DNS zone. The client answers its own server's name from a cache that takes precedence over custom zones, so the record is never served: other names in the same zone are, the server's name is not.
  • Split DNS that answers the server's name with an address only reachable through NetBird, served to remote devices through a nameserver group. The client picks up the new answer only at its next cache refresh, every 5 minutes, so for up to 5 minutes after each start the admin still gets 403. Worse, the client also uses the cached answer for its own management, signal and relay connections, and that address is reachable only through the tunnel those connections build. If these connections are cut on the device, the client dials them again at that address, cannot reach it, and stays disconnected until you restart the client. A restart of the NetBird server does not cause this: the client reconnects to the public address.

Split DNS that gives devices in your network the server's private address, as for the office admins, is a different case: they reach that address without NetBird.

Verify

From a host on the internet:

curl -s -o /dev/null -w '%{http_code}\n' https://netbird.example.com/
curl -s -o /dev/null -w '%{http_code}\n' https://netbird.example.com/api/users
curl -s -o /dev/null -w '%{http_code}\n' https://netbird.example.com/oauth2/.well-known/openid-configuration

The first two print 403; the third prints 200. From an allowlisted admin address (an office workstation that resolves the name to 10.0.2.10), and from Dana's device with NetBird up, the first prints 200 and the second 401 (the API still requires login). Then check that a client outside your network connects (netbird status shows Management and Signal Connected) and that new users can sign in through SSO.

Recap

For Dana and netbird.example.com:

  • Public: Management, Signal, /relay, /oauth2, UDP 3478, and on Enterprise /flow.FlowService/. TCP 80 can be closed with the built-in Traefik; with Existing Traefik, only if your certificate resolver does not use HTTP-01.
  • Internal: the dashboard, /api, /ws-proxy/, /management.ProxyService/, behind an allowlist of exact admin addresses.
  • Optional: the invite page and its two API paths, /api/reverse-proxy/callback, /api/scim/v2.
  • Dana reaches the dashboard by name through a route for 203.0.113.10/32 via a dedicated routing peer, not through DNS for the server's name.
  • After every upgrade or migration, run the Verify checks again.