From bc6cffcea64175fd8b67b07c4e3ea6f3a9be5e79 Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:14:41 +0200 Subject: [PATCH 1/8] fix: update self-hosted docs for the Traefik reverse proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getting-started-enterprise.sh` deploys Traefik instead of Caddy, but the enterprise getting-started page still described the Caddy stack. The worst of it was the custom-TLS appendix, which edits a `Caddyfile` the installer no longer generates, so it could not be followed at all. - Rewrite the custom TLS certificate appendix for Traefik: supply the certificate through the file provider, then remove both the ACME resolver flags and the four router `certresolver` labels. Removing the labels alone is not enough, because the certificate already stored in `acme.json` continues to be served in preference to the supplied one. - Document renewal correctly. Traefik watches the dynamic configuration file rather than the certificate files it references, so replacing the certificates has no effect until that file is touched. No container restart is needed. - Correct the certificate issuance guidance on the enterprise page and in certificate troubleshooting. The generated stack uses the TLS-ALPN-01 challenge only, so TCP/443 is what must be reachable; port 80 serves the HTTP→HTTPS redirect and is never used for validation. The community installer sets the same flag, so this applies to both. - Drop the `Caddyfile` row from the generated-files table. The installer writes `.env`, `docker-compose.yml` and `config.yaml` only, and configures routing and TLS through Traefik labels and command flags. - Replace `caddy` with `traefik` in the stack components table, the log commands, and the cleanup instructions, and fix `selfhosted-guide` still calling the bundled proxy Caddy where the same page elsewhere says Traefik. - Add the consequences that were previously unstated: a peer that does not trust a private issuing CA fails to connect, `config.yaml`'s empty `server.tls` block is intentional, and dropping the `80:80` mapping costs the HTTP→HTTPS redirect. --- .../selfhosted/enterprise/getting-started.mdx | 145 ++++++++++++------ src/pages/selfhosted/selfhosted-guide.mdx | 2 +- .../troubleshooting/certificates.mdx | 6 +- 3 files changed, 102 insertions(+), 51 deletions(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index 6afcb0793..ae6e52cf9 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -15,7 +15,7 @@ NetBird issues `getting-started-enterprise.sh` and the license key with your [En ## 1. Fresh Installation -`getting-started-enterprise.sh` deploys a single-node self-hosted NetBird stack with the embedded IdP. Management, signal, relay, and STUN run in one `netbird-server` container alongside Caddy, the dashboard, and Postgres. +`getting-started-enterprise.sh` deploys a single-node self-hosted NetBird stack with the embedded IdP. Management, signal, relay, and STUN run in one `netbird-server` container alongside Traefik, the dashboard, and Postgres. ### 1.1 Prerequisites @@ -23,7 +23,7 @@ NetBird issues `getting-started-enterprise.sh` and the license key with your [En - At least 5 GB of free disk space (the enterprise images and Postgres total ~2.2 GB, plus container and volume overhead). - `bash`, `curl`, `jq`, and `openssl` available on the host. - A real DNS-resolvable FQDN with an A record pointing at the host. Bare IP addresses are not supported. -- Open inbound ports: `80/tcp`, `443/tcp`, and `3478/udp`. +- Open inbound ports: `80/tcp` (HTTP→HTTPS redirect), `443/tcp` (dashboard, API, gRPC, relay, and Let's Encrypt validation), and `3478/udp` (STUN). - An enterprise license key authorized for the products you want to enable. ### 1.2 Run the script @@ -56,9 +56,10 @@ 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` | -| `Caddyfile` | Reverse proxy and automatic HTTPS configuration | `644` | | `config.yaml` | NetBird server configuration (YAML) | `600` | +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. + The script aborts if generated files already exist in the directory. This avoids overwriting secrets or replacing an existing deployment by accident. ### 1.4 First login @@ -70,7 +71,7 @@ Open `https://` in a browser. The dashboard detects that setup is r After the stack starts: - Run `docker compose ps` and confirm the expected services are running. -- Check `docker compose logs -f netbird-server caddy`. +- Check `docker compose logs -f netbird-server traefik`. - Open the dashboard and complete owner setup. - Add a test peer and confirm it connects. @@ -80,7 +81,7 @@ The combined stack: | Service | Image | Notes | | --- | --- | --- | -| `caddy` | `caddy:2` | TLS termination, reverse proxy | +| `traefik` | `traefik:v3.6` | TLS termination, reverse proxy, HTTP→HTTPS redirect | | `dashboard` | `ghcr.io/netbirdio/dashboard-cloud:latest` | UI | | `postgres` | `postgres:17` | Datastore for management, embedded IdP, traffic events | | `netbird-server` | `ghcr.io/netbirdio/netbird-server-cloud:latest` | Management + signal + relay + embedded STUN on UDP/3478 | @@ -253,12 +254,12 @@ Each entry follows the same structure: **Symptom → Cause → Resolution → Ve - **Resolution:** Run `docker compose ps`; if `postgres` isn't `healthy`, check `docker compose logs postgres`, then confirm the DSN uses host `postgres` with a matching user, database, and password. - **Verification:** `docker compose ps` shows `postgres` as `healthy`; the server connects once on startup with no connection-refused loop. -### Caddy cannot issue a TLS certificate +### Traefik cannot issue a TLS certificate -- **Symptom:** HTTPS to the dashboard is unreachable or shows a TLS error; `docker compose logs caddy` shows ACME challenge failures. -- **Cause:** The FQDN does not resolve to the host, or TCP/80 + TCP/443 are not reachable from Let's Encrypt's validation servers. Caddy tries HTTP-01 (port 80) first and falls back to TLS-ALPN-01 (port 443); at least one must be reachable from the public internet for cert issuance to succeed. -- **Resolution:** Confirm the DNS A record for `NETBIRD_DOMAIN` points at the host, and that the firewall / cloud security group allows inbound TCP/80 and TCP/443. -- **Verification:** `docker compose logs caddy` shows "certificate obtained successfully"; `curl -sI https:///` returns a valid response with a trusted certificate. +- **Symptom:** HTTPS to the dashboard is unreachable or shows a TLS error; `docker compose logs traefik` shows ACME challenge failures. +- **Cause:** The FQDN does not resolve to the host, or **TCP/443** is not reachable from Let's Encrypt's validation servers. The generated stack validates with the TLS-ALPN-01 challenge only (`--certificatesresolvers.letsencrypt.acme.tlschallenge=true`), which always runs over port 443. Port 80 is used for the HTTP→HTTPS redirect and is never used for certificate validation, so opening it does not help issuance. +- **Resolution:** Confirm the DNS A record for `NETBIRD_DOMAIN` points at the host, and that the firewall / cloud security group allows inbound TCP/443. +- **Verification:** `docker compose logs traefik` shows `Validations succeeded; requesting certificates` followed by `Server responded with a certificate`; `curl -sI https:///` returns a valid response with a trusted certificate. ### First-login owner-setup page is not shown @@ -267,49 +268,97 @@ 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 Caddyfile config.yaml + rm -f .env docker-compose.yml config.yaml 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. ## Appendix: Using a custom TLS certificate -By default the generated `Caddyfile` uses Caddy's automatic TLS via Let's Encrypt. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can swap to their own cert with three manual edits after the script runs. Caddy stays in front of the stack either way — all reverse-proxy routes (signal, management, OAuth2, relay, dashboard) flow through it. - -1. **Place the cert and key on the host.** PEM-encoded, full chain in the cert file: - ```bash - sudo mkdir -p /etc/netbird/certs - sudo cp /path/to/fullchain.pem /etc/netbird/certs/cert.pem - sudo cp /path/to/privkey.pem /etc/netbird/certs/key.pem - sudo chmod 600 /etc/netbird/certs/key.pem - ``` - -2. **Edit `Caddyfile`** and add a `tls` directive inside the `:443` block: - ``` - {$CADDY_SECURE_DOMAIN}:443 { - tls /etc/caddy/certs/cert.pem /etc/caddy/certs/key.pem - import security_headers - # … existing reverse_proxy lines unchanged … - } - ``` - -3. **Edit `docker-compose.yml`** and add a read-only volume mount on the `caddy` service: - ```yaml - caddy: - volumes: - - netbird_caddy_data:/data - - ./Caddyfile:/etc/caddy/Caddyfile - - /etc/netbird/certs:/etc/caddy/certs:ro - ``` - -4. **Apply the change:** - ```bash - docker compose up -d caddy - ``` - -Notes: +By default, Traefik obtains a certificate from Let's Encrypt. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can serve their own instead. Traefik stays in front of the stack either way — all routes (dashboard, management and signal gRPC, OAuth2, relay, and traffic flow) pass through it. + +Traefik accepts a static certificate only through its **file provider**, which the generated stack does not enable. + + +Leave `config.yaml` alone. `netbird-server` listens on plain HTTP behind Traefik, so its empty `server.tls` block has no effect on what clients see. + + +### Place the certificate and key on the host + +PEM-encoded, with the full chain in the certificate file: + +```bash +sudo mkdir -p /etc/netbird/certs +sudo cp /path/to/fullchain.pem /etc/netbird/certs/cert.pem +sudo cp /path/to/privkey.pem /etc/netbird/certs/key.pem +sudo chmod 600 /etc/netbird/certs/key.pem +``` + +### Create the dynamic configuration file + +Create `traefik-dynamic.yaml` next to `docker-compose.yml`: + +```yaml +tls: + stores: + default: + defaultCertificate: + certFile: /certs/cert.pem + keyFile: /certs/key.pem +``` + +This serves the certificate on every connection, including from clients that send no SNI. + +### Update `docker-compose.yml` + +**Enable the file provider** — add to the `traefik` service's `command:` list: + +```yaml + - "--providers.file.filename=/etc/traefik/dynamic.yaml" +``` + +**Mount the configuration and the certificates** — add to its `volumes:` list: + +```yaml + - ./traefik-dynamic.yaml:/etc/traefik/dynamic.yaml:ro + - /etc/netbird/certs:/certs:ro +``` + +**Remove ACME** — delete these three lines from the same `command:` list: + +```yaml + - "--certificatesresolvers.letsencrypt.acme.email=${NETBIRD_LETSENCRYPT_EMAIL}" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" +``` + +…and the four router labels that reference the resolver — one on `dashboard`, two on `netbird-server`, and one on `receiver` if you enabled traffic flow: + +```yaml + - traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt + - traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt + - traefik.http.routers.netbird-backend.tls.certresolver=letsencrypt + - traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt +``` + +Leave the corresponding `traefik.http.routers.*.tls=true` labels in place. + + +Deleting the labels alone is not enough. While the `certificatesresolvers` lines remain, Traefik keeps serving the certificate the installer already stored in `acme.json` in preference to the one you supplied, so the stack appears to ignore your certificate. Remove both. + + +### Apply and verify + +```bash +docker compose up -d +echo | openssl s_client -connect :443 -servername 2>/dev/null \ + | openssl x509 -noout -subject -issuer -enddate +``` + +### Operational notes - The certificate must cover `NETBIRD_DOMAIN` from `.env`. A wildcard like `*.example.com` works for `netbird.example.com`. -- If the cert is signed by a private CA, every peer and CLI client must trust the issuing CA — install the CA bundle in their trust stores. -- Renewals are the operator's responsibility. Replace the files at the same paths and run `docker compose restart caddy`. Caddy will pick up the new cert without a full restart of the rest of the stack. -- Port 80 in the generated `Caddyfile` only does an HTTP→HTTPS redirect; it is not used for ACME challenges in this mode. You can drop the `:80` mapping from the `caddy` service if HTTP isn't needed. +- **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`. +- **Renewals.** Replace both files at the same paths, then `touch traefik-dynamic.yaml`. Traefik watches that file rather than the certificates it references, so replacing the certificates alone has no effect; touching it reloads them without restarting any container. +- You can leave the `netbird_traefik_letsencrypt` volume in place. With no resolver configured, the certificate stored in `acme.json` is inert. +- 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, so `http://` stops responding instead of redirecting. diff --git a/src/pages/selfhosted/selfhosted-guide.mdx b/src/pages/selfhosted/selfhosted-guide.mdx index 3df643630..ffca268af 100644 --- a/src/pages/selfhosted/selfhosted-guide.mdx +++ b/src/pages/selfhosted/selfhosted-guide.mdx @@ -65,7 +65,7 @@ Since version 0.29, NetBird uses a consolidated port architecture where Manageme | 3478 | UDP | Coturn STUN/TURN server | -The default setup script configures a Caddy reverse proxy that consolidates all services behind ports 80 and 443. If you're running NetBird behind your own reverse proxy, see the [advanced configuration](#advanced-running-net-bird-behind-an-existing-reverse-proxy) section. +The default setup script configures a Traefik reverse proxy that consolidates all services behind ports 80 and 443. If you're running NetBird behind your own reverse proxy, see the [advanced configuration](#advanced-running-net-bird-behind-an-existing-reverse-proxy) section. diff --git a/src/pages/selfhosted/troubleshooting/certificates.mdx b/src/pages/selfhosted/troubleshooting/certificates.mdx index c0cefd6fb..6d7bc70ee 100644 --- a/src/pages/selfhosted/troubleshooting/certificates.mdx +++ b/src/pages/selfhosted/troubleshooting/certificates.mdx @@ -11,9 +11,11 @@ TLS and certificate problems on a self-hosted deployment. For other areas, start **Likely causes and fixes** (most common first): -1. **Port 80 is not reachable from the internet.** The ACME HTTP challenge (how Let's Encrypt validates your domain) needs inbound TCP/80. Confirm your firewall and cloud security groups allow it. +1. **Port 443 is not reachable from the internet.** The bundled Traefik proxy validates with the TLS-ALPN-01 challenge, which runs over inbound TCP/443. Confirm your firewall and cloud security groups allow it. Port 80 only serves the HTTP→HTTPS redirect and is not used for validation, so opening it does not fix issuance. 2. **The domain no longer points at this host.** Verify the `A`/`AAAA` record resolves to the server's public IP. -3. **A renewal error in the proxy.** Check the certificate manager's logs: `docker compose logs caddy`. If needed, force a reload: `docker exec -it netbird-caddy caddy reload`. +3. **A renewal error in the proxy.** Check the proxy's logs: `docker compose logs traefik`. Traefik reloads its configuration on its own, so there is no reload command; if you need to force a retry, restart the container with `docker compose restart traefik`. + +Deployments created before NetBird switched to Traefik may still run Caddy, which can also use the HTTP-01 challenge on port 80. On those, keep inbound TCP/80 open and check `docker compose logs caddy` instead. **Confirm**: `curl -vI https://YOUR_DOMAIN 2>&1 | grep -E "issuer|expire"` shows a current Let's Encrypt certificate. From 4c44520435d5c39a71b04da501c30dd10ca99df9 Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:28:31 +0200 Subject: [PATCH 2/8] fix: mount the Traefik dynamic config as a directory, not a single file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bind-mounted single file is pinned to one inode, so any tool that replaces the file rather than editing it in place — most editors, `sed -i`, many configuration-management tools — leaves the container reading the old content indefinitely, with no error and no way to recover by touching the new file. Traefik's own documentation recommends `directory` over `filename` for this reason. Switch to `--providers.file.directory=/etc/traefik/dynamic` with `./traefik` bind-mounted, and move the dynamic configuration to `traefik/dynamic.yaml`. The renewal instruction is otherwise unchanged: the certificates are referenced rather than watched, so they are picked up by touching the dynamic configuration, without restarting any container. Also drop the `tls.certificates` list from the example. It is redundant next to `stores.default.defaultCertificate`, which already covers every connection including clients that send no SNI. --- .../selfhosted/enterprise/getting-started.mdx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index ae6e52cf9..26050a3dd 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -296,7 +296,7 @@ sudo chmod 600 /etc/netbird/certs/key.pem ### Create the dynamic configuration file -Create `traefik-dynamic.yaml` next to `docker-compose.yml`: +Create `traefik/dynamic.yaml` next to `docker-compose.yml`: ```yaml tls: @@ -314,16 +314,20 @@ This serves the certificate on every connection, including from clients that sen **Enable the file provider** — add to the `traefik` service's `command:` list: ```yaml - - "--providers.file.filename=/etc/traefik/dynamic.yaml" + - "--providers.file.directory=/etc/traefik/dynamic" ``` -**Mount the configuration and the certificates** — add to its `volumes:` list: +**Mount the configuration directory and the certificates** — add to its `volumes:` list: ```yaml - - ./traefik-dynamic.yaml:/etc/traefik/dynamic.yaml:ro + - ./traefik:/etc/traefik/dynamic:ro - /etc/netbird/certs:/certs:ro ``` + +Mount the directory, not the file. A bind-mounted single file is pinned to one inode, so any tool that replaces the file rather than editing it in place — most editors, `sed -i`, and many configuration-management tools — leaves the container reading the old file indefinitely, with no error. Traefik's own documentation recommends `directory` over `filename` for the same reason. + + **Remove ACME** — delete these three lines from the same `command:` list: ```yaml @@ -359,6 +363,6 @@ 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`. -- **Renewals.** Replace both files at the same paths, then `touch traefik-dynamic.yaml`. Traefik watches that file rather than the certificates it references, so replacing the certificates alone has no effect; touching it reloads them without restarting any container. +- **Renewals.** Replace both files at the same paths, then `touch traefik/dynamic.yaml`. Traefik watches the dynamic configuration rather than the certificates it references, so replacing the certificates alone has no effect; touching it reloads them without restarting any container. - You can leave the `netbird_traefik_letsencrypt` volume in place. With no resolver configured, the certificate stored in `acme.json` is inert. - 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, so `http://` stops responding instead of redirecting. From 6e0666ec50a314ffd615b3b9ef0398c09543ed1e Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:34:51 +0200 Subject: [PATCH 3/8] docs: scope the custom TLS appendix to the fresh enterprise install The appendix sits on a page that also documents `migrate-to-enterprise.sh`, and the two paths differ. A migrated deployment is built on the community `getting-started.sh` render, which already configures a file provider when the reverse proxy is enabled. `providers.file.filename` and `providers.file.directory` are mutually exclusive, so following these steps verbatim there would declare a conflicting second provider. Note that deployments which already have a file provider should extend its dynamic configuration instead, and adjust router names to match their own Compose file. --- src/pages/selfhosted/enterprise/getting-started.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index 26050a3dd..111f94394 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -277,7 +277,11 @@ Each entry follows the same structure: **Symptom → Cause → Resolution → Ve By default, Traefik obtains a certificate from Let's Encrypt. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can serve their own instead. Traefik stays in front of the stack either way — all routes (dashboard, management and signal gRPC, OAuth2, relay, and traffic flow) pass through it. -Traefik accepts a static certificate only through its **file provider**, which the generated stack does not enable. +Traefik accepts a static certificate only through its **file provider**, which the stack from `getting-started-enterprise.sh` does not enable. + + +These steps describe the stack rendered by `getting-started-enterprise.sh`. If your Traefik already has a file provider — for example a deployment migrated from community with the reverse proxy enabled — add the `tls` block below to its existing dynamic configuration rather than declaring a second provider, and adjust the router names to match your own Compose file. + Leave `config.yaml` alone. `netbird-server` listens on plain HTTP behind Traefik, so its empty `server.tls` block has no effect on what clients see. From 60c705765a6e12dd039c7e4f987e704a3ca388d1 Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:39:03 +0200 Subject: [PATCH 4/8] docs: apply NetBird house style to the custom TLS appendix Replace the em dashes added by the previous commits with commas, colons and parentheses. House convention is to reach for an em dash deliberately or not at all, and the appendix had accumulated eleven of them. Also state when not to follow the appendix at all: the default Let's Encrypt path renews itself, and everything in the appendix makes renewal the operator's responsibility. Expand SNI on first use. --- .../selfhosted/enterprise/getting-started.mdx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index 111f94394..ae6fb8543 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -275,12 +275,12 @@ Each entry follows the same structure: **Symptom → Cause → Resolution → Ve ## Appendix: Using a custom TLS certificate -By default, Traefik obtains a certificate from Let's Encrypt. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can serve their own instead. Traefik stays in front of the stack either way — all routes (dashboard, management and signal gRPC, OAuth2, relay, and traffic flow) pass through it. +By default, Traefik obtains a certificate from Let's Encrypt and renews it automatically. Stay on that path unless you need your own certificate, because everything below makes renewal your responsibility. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can serve their own certificate instead. Traefik stays in front of the stack either way: all routes (dashboard, management and signal gRPC, OAuth2, relay, and traffic flow) pass through it. Traefik accepts a static certificate only through its **file provider**, which the stack from `getting-started-enterprise.sh` does not enable. -These steps describe the stack rendered by `getting-started-enterprise.sh`. If your Traefik already has a file provider — for example a deployment migrated from community with the reverse proxy enabled — add the `tls` block below to its existing dynamic configuration rather than declaring a second provider, and adjust the router names to match your own Compose file. +These steps describe the stack rendered by `getting-started-enterprise.sh`. If your Traefik already has a file provider (for example, a deployment migrated from community with the reverse proxy enabled), add the `tls` block below to its existing dynamic configuration rather than declaring a second provider, and adjust the router names to match your own Compose file. @@ -311,17 +311,17 @@ tls: keyFile: /certs/key.pem ``` -This serves the certificate on every connection, including from clients that send no SNI. +This serves the certificate on every connection, including from clients that send no Server Name Indication (SNI), the TLS field that tells the proxy which hostname is being requested. ### Update `docker-compose.yml` -**Enable the file provider** — add to the `traefik` service's `command:` list: +**Enable the file provider.** Add this to the `traefik` service's `command:` list: ```yaml - "--providers.file.directory=/etc/traefik/dynamic" ``` -**Mount the configuration directory and the certificates** — add to its `volumes:` list: +**Mount the configuration directory and the certificates.** Add these to its `volumes:` list: ```yaml - ./traefik:/etc/traefik/dynamic:ro @@ -329,10 +329,10 @@ This serves the certificate on every connection, including from clients that sen ``` -Mount the directory, not the file. A bind-mounted single file is pinned to one inode, so any tool that replaces the file rather than editing it in place — most editors, `sed -i`, and many configuration-management tools — leaves the container reading the old file indefinitely, with no error. Traefik's own documentation recommends `directory` over `filename` for the same reason. +Mount the directory, not the file. A bind-mounted single file is pinned to one inode, so any tool that replaces the file rather than editing it in place (most editors, `sed -i`, and many configuration-management tools) leaves the container reading the old file indefinitely, with no error. Traefik's own documentation recommends `directory` over `filename` for the same reason. -**Remove ACME** — delete these three lines from the same `command:` list: +**Remove ACME.** Delete these three lines from the same `command:` list: ```yaml - "--certificatesresolvers.letsencrypt.acme.email=${NETBIRD_LETSENCRYPT_EMAIL}" @@ -340,7 +340,7 @@ Mount the directory, not the file. A bind-mounted single file is pinned to one i - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" ``` -…and the four router labels that reference the resolver — one on `dashboard`, two on `netbird-server`, and one on `receiver` if you enabled traffic flow: +Then delete the four router labels that reference the resolver, one on `dashboard`, two on `netbird-server`, and one on `receiver` if you enabled traffic flow: ```yaml - traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt @@ -366,7 +366,7 @@ echo | openssl s_client -connect :443 -servername 2>/ ### Operational notes - 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`. +- **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`. - **Renewals.** Replace both files at the same paths, then `touch traefik/dynamic.yaml`. Traefik watches the dynamic configuration rather than the certificates it references, so replacing the certificates alone has no effect; touching it reloads them without restarting any container. - You can leave the `netbird_traefik_letsencrypt` volume in place. With no resolver configured, the certificate stored in `acme.json` is inert. -- 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, so `http://` stops responding instead of redirecting. +- 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, so `http://` stops responding instead of redirecting. From dd14ed9e82575781e9cbff3c28f1df81e4517fcc Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:52:32 +0200 Subject: [PATCH 5/8] docs: correct the migrate-path guidance in the custom TLS appendix Running the appendix against a real migrated deployment (community install with the built-in Traefik, then migrate-to-enterprise.sh with Postgres and traffic flow) showed the previous note pointed at the wrong difference. The four router names are identical to the fresh install, so there is nothing to adjust there. What actually differs is which file holds each label. `netbird-dashboard`, `netbird-grpc` and `netbird-backend` are in `docker-compose.yml`, while `netbird-flow` is on the `flow-receiver` service in `docker-compose.override.yml`, so the deletions span two files. The `traefik` service and the ACME flags stay in `docker-compose.yml`. Also state why a deployment that already has a file provider must extend it rather than add a second one: `providers.file.directory` and `providers.file.filename` are mutually exclusive. --- src/pages/selfhosted/enterprise/getting-started.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index ae6fb8543..05fe443cd 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -280,7 +280,10 @@ By default, Traefik obtains a certificate from Let's Encrypt and renews it autom Traefik accepts a static certificate only through its **file provider**, which the stack from `getting-started-enterprise.sh` does not enable. -These steps describe the stack rendered by `getting-started-enterprise.sh`. If your Traefik already has a file provider (for example, a deployment migrated from community with the reverse proxy enabled), add the `tls` block below to its existing dynamic configuration rather than declaring a second provider, and adjust the router names to match your own Compose file. +These steps describe the stack rendered by `getting-started-enterprise.sh`. Two adjustments apply elsewhere: + +- **A deployment migrated with `migrate-to-enterprise.sh`.** The four router names are the same, but the labels are split across two files. `netbird-dashboard`, `netbird-grpc` and `netbird-backend` are in `docker-compose.yml`, while `netbird-flow` is on the `flow-receiver` service in `docker-compose.override.yml`. The `traefik` service and the ACME flags are in `docker-compose.yml`, so both additions go there. +- **A Traefik that already has a file provider**, such as a community install with the reverse proxy enabled. Add the `tls` block to that provider's existing dynamic configuration instead of declaring a second one, because `providers.file.directory` and `providers.file.filename` are mutually exclusive. From b593d20cb97221319185de1c4f7946c18eb7cb9e Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:57:50 +0200 Subject: [PATCH 6/8] docs: tighten the custom TLS appendix The appendix had grown to four stacked callouts, two of them before the reader reaches the first step. Cut it from 690 to 554 words and from four callouts to two, without dropping anything load-bearing. Move the migrate-path and existing-file-provider caveats out of a 106 word preamble block and into the two steps they actually affect. Drop a decorative sentence about all routes passing through Traefik, shorten the inode note, and trim the SNI gloss to expanding the acronym. --- .../selfhosted/enterprise/getting-started.mdx | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index 05fe443cd..ecc99a4c4 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -275,16 +275,7 @@ Each entry follows the same structure: **Symptom → Cause → Resolution → Ve ## Appendix: Using a custom TLS certificate -By default, Traefik obtains a certificate from Let's Encrypt and renews it automatically. Stay on that path unless you need your own certificate, because everything below makes renewal your responsibility. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can serve their own certificate instead. Traefik stays in front of the stack either way: all routes (dashboard, management and signal gRPC, OAuth2, relay, and traffic flow) pass through it. - -Traefik accepts a static certificate only through its **file provider**, which the stack from `getting-started-enterprise.sh` does not enable. - - -These steps describe the stack rendered by `getting-started-enterprise.sh`. Two adjustments apply elsewhere: - -- **A deployment migrated with `migrate-to-enterprise.sh`.** The four router names are the same, but the labels are split across two files. `netbird-dashboard`, `netbird-grpc` and `netbird-backend` are in `docker-compose.yml`, while `netbird-flow` is on the `flow-receiver` service in `docker-compose.override.yml`. The `traefik` service and the ACME flags are in `docker-compose.yml`, so both additions go there. -- **A Traefik that already has a file provider**, such as a community install with the reverse proxy enabled. Add the `tls` block to that provider's existing dynamic configuration instead of declaring a second one, because `providers.file.directory` and `providers.file.filename` are mutually exclusive. - +By default, Traefik obtains a certificate from Let's Encrypt and renews it automatically. Stay on that path unless you need your own certificate, because everything below makes renewal your responsibility. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can serve their own certificate through Traefik's **file provider**, which the generated stack does not enable. Leave `config.yaml` alone. `netbird-server` listens on plain HTTP behind Traefik, so its empty `server.tls` block has no effect on what clients see. @@ -314,7 +305,7 @@ tls: keyFile: /certs/key.pem ``` -This serves the certificate on every connection, including from clients that send no Server Name Indication (SNI), the TLS field that tells the proxy which hostname is being requested. +This serves the certificate on every connection, including clients that send no SNI (Server Name Indication). If your Traefik already has a file provider, add this `tls` block to its existing dynamic configuration and skip the next step's first edit: `providers.file.directory` and `providers.file.filename` are mutually exclusive. ### Update `docker-compose.yml` @@ -331,9 +322,7 @@ This serves the certificate on every connection, including from clients that sen - /etc/netbird/certs:/certs:ro ``` - -Mount the directory, not the file. A bind-mounted single file is pinned to one inode, so any tool that replaces the file rather than editing it in place (most editors, `sed -i`, and many configuration-management tools) leaves the container reading the old file indefinitely, with no error. Traefik's own documentation recommends `directory` over `filename` for the same reason. - +Mount the directory, not the file. A bind-mounted single file is pinned to one inode, so anything that replaces the file instead of editing it in place leaves the container reading the old one, silently. **Remove ACME.** Delete these three lines from the same `command:` list: @@ -352,7 +341,7 @@ Then delete the four router labels that reference the resolver, one on `dashboar - traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt ``` -Leave the corresponding `traefik.http.routers.*.tls=true` labels in place. +Leave the corresponding `traefik.http.routers.*.tls=true` labels in place. On a deployment migrated with `migrate-to-enterprise.sh` the router names are the same, but the `netbird-flow` label sits on the `flow-receiver` service in `docker-compose.override.yml`. Deleting the labels alone is not enough. While the `certificatesresolvers` lines remain, Traefik keeps serving the certificate the installer already stored in `acme.json` in preference to the one you supplied, so the stack appears to ignore your certificate. Remove both. @@ -370,6 +359,6 @@ 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`. -- **Renewals.** Replace both files at the same paths, then `touch traefik/dynamic.yaml`. Traefik watches the dynamic configuration rather than the certificates it references, so replacing the certificates alone has no effect; touching it reloads them without restarting any container. -- You can leave the `netbird_traefik_letsencrypt` volume in place. With no resolver configured, the certificate stored in `acme.json` is inert. -- 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, so `http://` stops responding instead of redirecting. +- **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. +- 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. From f60fecfd7a3d874c3f7647d6466d9de0abf7acc9 Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:04:24 +0200 Subject: [PATCH 7/8] docs: find the resolver labels by grep instead of enumerating them The appendix is the supported way to serve a custom certificate, not a stopgap, so it should not hard-code "four labels, one on dashboard, two on netbird-server, one on receiver". That count goes stale the moment a router is added, and silently. Replace the enumeration with a grep over `docker-compose*.yml`. It is shorter, survives new routers, and spans `docker-compose.override.yml` on a migrated deployment, which removes the need for a separate note about where the `netbird-flow` label lives. Also reword the intro so the file provider reads as the mechanism you enable rather than something the installer failed to configure. --- .../selfhosted/enterprise/getting-started.mdx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index ecc99a4c4..367c80926 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -275,7 +275,7 @@ Each entry follows the same structure: **Symptom → Cause → Resolution → Ve ## Appendix: Using a custom TLS certificate -By default, Traefik obtains a certificate from Let's Encrypt and renews it automatically. Stay on that path unless you need your own certificate, because everything below makes renewal your responsibility. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate can serve their own certificate through Traefik's **file provider**, which the generated stack does not enable. +By default, Traefik obtains a certificate from Let's Encrypt and renews it automatically. Stay on that path unless you need your own certificate, because everything below makes renewal your responsibility. Operators using an internal PKI, a corporate CA, or a pre-issued wildcard certificate serve their own certificate through Traefik's **file provider**, which you enable below. Leave `config.yaml` alone. `netbird-server` listens on plain HTTP behind Traefik, so its empty `server.tls` block has no effect on what clients see. @@ -332,16 +332,19 @@ Mount the directory, not the file. A bind-mounted single file is pinned to one i - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" ``` -Then delete the four router labels that reference the resolver, one on `dashboard`, two on `netbird-server`, and one on `receiver` if you enabled traffic flow: +Then delete every router label that references the resolver. A default install has four, but list them rather than counting, so you also catch `docker-compose.override.yml` on a migrated deployment: + +```bash +grep -n 'tls.certresolver' docker-compose*.yml +``` + +Each match looks like this, and all of them go: ```yaml - traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt - - traefik.http.routers.netbird-grpc.tls.certresolver=letsencrypt - - traefik.http.routers.netbird-backend.tls.certresolver=letsencrypt - - traefik.http.routers.netbird-flow.tls.certresolver=letsencrypt ``` -Leave the corresponding `traefik.http.routers.*.tls=true` labels in place. On a deployment migrated with `migrate-to-enterprise.sh` the router names are the same, but the `netbird-flow` label sits on the `flow-receiver` service in `docker-compose.override.yml`. +Leave the corresponding `traefik.http.routers.*.tls=true` labels in place. Deleting the labels alone is not enough. While the `certificatesresolvers` lines remain, Traefik keeps serving the certificate the installer already stored in `acme.json` in preference to the one you supplied, so the stack appears to ignore your certificate. Remove both. From d46204af37ce6f637c461cc4fd405b0ef87d509d Mon Sep 17 00:00:00 2001 From: Jack Carter <128555021+SunsetDrifter@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:08:19 +0200 Subject: [PATCH 8/8] docs: fix three gaps in the custom TLS appendix - The `traefik` directory was never created. "Create `traefik/dynamic.yaml`" fails in any editor that will not create a missing parent, so add the `mkdir -p traefik` the steps assumed. - `/certs` appeared in the dynamic configuration one step before the mount that defines it. Say that it is a container path and where it comes from. - Point the file-provider caveat at the named edit it refers to instead of "the next step's first edit", and reword the grep sentence. --- .../selfhosted/enterprise/getting-started.mdx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pages/selfhosted/enterprise/getting-started.mdx b/src/pages/selfhosted/enterprise/getting-started.mdx index 367c80926..1f2b9807d 100644 --- a/src/pages/selfhosted/enterprise/getting-started.mdx +++ b/src/pages/selfhosted/enterprise/getting-started.mdx @@ -294,7 +294,13 @@ sudo chmod 600 /etc/netbird/certs/key.pem ### Create the dynamic configuration file -Create `traefik/dynamic.yaml` next to `docker-compose.yml`: +Create a `traefik` directory next to `docker-compose.yml` and put a `dynamic.yaml` in it: + +```bash +mkdir -p traefik +``` + +`traefik/dynamic.yaml`: ```yaml tls: @@ -305,7 +311,9 @@ tls: keyFile: /certs/key.pem ``` -This serves the certificate on every connection, including clients that send no SNI (Server Name Indication). If your Traefik already has a file provider, add this `tls` block to its existing dynamic configuration and skip the next step's first edit: `providers.file.directory` and `providers.file.filename` are mutually exclusive. +`/certs` is a path inside the container; the next step mounts `/etc/netbird/certs` there. Setting `defaultCertificate` serves this certificate on every connection, including from clients that send no SNI (Server Name Indication). + +If your Traefik already has a file provider, add this `tls` block to its existing dynamic configuration and skip the **Enable the file provider** edit below, because `providers.file.directory` and `providers.file.filename` are mutually exclusive. ### Update `docker-compose.yml` @@ -332,7 +340,7 @@ Mount the directory, not the file. A bind-mounted single file is pinned to one i - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" ``` -Then delete every router label that references the resolver. A default install has four, but list them rather than counting, so you also catch `docker-compose.override.yml` on a migrated deployment: +Then delete every router label that references the resolver. A fresh install has four. List them rather than relying on that count, so you also catch `docker-compose.override.yml` on a migrated deployment: ```bash grep -n 'tls.certresolver' docker-compose*.yml