fix: update self-hosted docs for the Traefik reverse proxy (#911)

* fix: update self-hosted docs for the Traefik reverse proxy

`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.

* fix: mount the Traefik dynamic config as a directory, not a single 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`, 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.
This commit is contained in:
Jack Carter
2026-08-10 17:06:13 +02:00
committed by GitHub
parent fd04046f5f
commit 4061897914
3 changed files with 108 additions and 46 deletions

View File

@@ -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://<your-domain>` 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://<your-domain>/` 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://<your-domain>/` returns a valid response with a trusted certificate.
### First-login owner-setup page is not shown
@@ -267,49 +268,108 @@ 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://<your-domain>/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.
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.
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
```
<Note>
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.
</Note>
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 …
}
```
### Place the certificate and key on the host
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
```
PEM-encoded, with the full chain in the certificate file:
4. **Apply the change:**
```bash
docker compose up -d caddy
```
```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
```
Notes:
### Create the dynamic configuration file
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:
stores:
default:
defaultCertificate:
certFile: /certs/cert.pem
keyFile: /certs/key.pem
```
`/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`
**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 these to its `volumes:` list:
```yaml
- ./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 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:
```yaml
- "--certificatesresolvers.letsencrypt.acme.email=${NETBIRD_LETSENCRYPT_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
```
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
```
Each match looks like this, and all of them go:
```yaml
- traefik.http.routers.netbird-dashboard.tls.certresolver=letsencrypt
```
Leave the corresponding `traefik.http.routers.*.tls=true` labels in place.
<Warning>
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.
</Warning>
### Apply and verify
```bash
docker compose up -d
echo | openssl s_client -connect <your-domain>:443 -servername <your-domain> 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 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.

View File

@@ -65,7 +65,7 @@ Since version 0.29, NetBird uses a consolidated port architecture where Manageme
| 3478 | UDP | Coturn STUN/TURN server |
<Note>
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.
</Note>
<Note>

View File

@@ -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.