From 2c805369e981f801fb0c962c775353e61d69701e Mon Sep 17 00:00:00 2001 From: Bruno Mercier Costa <94469565+emrcbrn@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:24:59 +0200 Subject: [PATCH] docs: fix default.json examples that stop the daemon from starting (#988) Every `default.json` example on the bootstrap page had at least one value the client cannot parse, and a bad value there is fatal: the daemon exits rather than falling back to defaults. - `ManagementURL` and `AdminURL` were shown as strings. `Config.ManagementURL` is a `*url.URL`, so the daemon dies with `cannot unmarshal string into Go struct field Config.ManagementURL of type url.URL`. Reproduced on 0.69.0, 0.73.0, 0.78.2 and 0.79.0-rc.1. - The Docker example bind-mounted the single file. The client rewrites `default.json` on first start via temp-file-plus-rename, and a rename cannot replace a bind-mounted file, so the daemon dies with `device or resource busy`. This happens with and without `:ro`. Mount the directory instead. - The Kubernetes example mounted the ConfigMap at the file path with `subPath`, which is the same read-only single-file mount. Seed a writable `emptyDir` from the ConfigMap with an init container instead. Verified in a systemd container against the real client: the corrected `default.json` starts cleanly, keeps the templated values, and regenerates `PrivateKey`. The fields left untouched (`IFaceBlackList` as an array, the empty `PrivateKey`, the platform path table, `status --check` values) were checked and are correct. The Kubernetes manifest is the one change not run end to end: no cluster was available. Both failure modes it avoids were reproduced directly with equivalent mounts, and the pattern it uses is the verified-working one. Co-authored-by: Claude Opus 5 (1M context) --- .../peers/bootstrap-via-config-file.mdx | 64 +++++++++++++++---- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/src/pages/manage/peers/bootstrap-via-config-file.mdx b/src/pages/manage/peers/bootstrap-via-config-file.mdx index 535bc553..105f55d4 100644 --- a/src/pages/manage/peers/bootstrap-via-config-file.mdx +++ b/src/pages/manage/peers/bootstrap-via-config-file.mdx @@ -32,8 +32,8 @@ A representative `default.json`: ```json { - "ManagementURL": "https://api.netbird.io:443", - "AdminURL": "https://app.netbird.io:443", + "ManagementURL": { "Scheme": "https", "Host": "api.netbird.io:443" }, + "AdminURL": { "Scheme": "https", "Host": "app.netbird.io:443" }, "WgIface": "wt0", "IFaceBlackList": ["docker", "br-", "veth"], "BlockInbound": false, @@ -44,9 +44,20 @@ A representative `default.json`: } ``` + +`ManagementURL` and `AdminURL` are objects, not strings. Writing `"ManagementURL": "https://api.netbird.io:443"` stops the daemon from starting at all: + +```shell +FATL failed to start daemon: failed to get config: + json: cannot unmarshal string into Go struct field Config.ManagementURL of type url.URL +``` + +The same applies to every field: a value of the wrong JSON type fails the whole file, not just that key, and the daemon exits instead of falling back to defaults. Start the client after writing the file and confirm it is running. + + Notable fields: -- `ManagementURL` and `AdminURL` — only needed when you point at a self-hosted deployment. The cloud defaults are `https://api.netbird.io:443` and `https://app.netbird.io:443`. +- `ManagementURL` and `AdminURL` — only needed when you point at a self-hosted deployment. The cloud defaults are `https://api.netbird.io:443` and `https://app.netbird.io:443`. `Scheme` and `Host` are enough; the client rewrites the object with the remaining URL fields on first start. - `WgIface` — WireGuard interface name (defaults to `wt0`). - `IFaceBlackList` — interfaces the client should ignore when listening for connections. - `BlockInbound`, `BlockLANAccess`, `RosenpassEnabled` — feature toggles that mirror the [`netbird up` flags](/get-started/cli#up). @@ -68,18 +79,33 @@ The setup key is **not** stored in `default.json`. Pass it per-launch so it can ### Docker -Mount a pre-baked `default.json` into the volume the daemon reads from, and inject the setup key from your shell or a secret store: +Put `default.json` in a directory and mount **the directory**, writable, at `/var/lib/netbird/`. It has to be writable because the daemon keeps its state there: it rewrites `default.json` and creates `active_profile.json` beside it. Inject the setup key from your shell or a secret store: ```shell +mkdir -p netbird-state +cp default.json netbird-state/ + docker run -d --name netbird --cap-add=NET_ADMIN \ - -v "$(pwd)/default.json:/var/lib/netbird/default.json:ro" \ + -v "$(pwd)/netbird-state:/var/lib/netbird" \ -e NB_SETUP_KEY="$NB_SETUP_KEY" \ netbirdio/netbird:latest ``` + +Do not bind-mount the single file (`-v "$(pwd)/default.json:/var/lib/netbird/default.json"`). The daemon rewrites `default.json` on first start, because it has a private key and a generated SSH key to add, and it writes it by creating a temporary file and renaming it into place. A rename cannot replace a bind-mounted file, so the daemon exits: + +```shell +FATL failed to start daemon: failed to get config: + move /var/lib/netbird/.1478389823default.json to /var/lib/netbird/default.json: + device or resource busy +``` + +Adding `:ro` does not help: it fails at the same point with the same error. + + ### Kubernetes -Bake the config into a `ConfigMap`, source the setup key from a `Secret`, and mount both into the pod. Pair this with the liveness, readiness, and startup probes from [Deploy routing peers to a Kubernetes cluster](/use-cases/kubernetes/routing-peers-and-kubernetes) for a complete deployment. +A `ConfigMap` is mounted read-only, so it cannot be the state directory: the daemon has to rewrite `default.json` and create `active_profile.json` next to it. Copy the `ConfigMap` into a writable `emptyDir` with an init container, then mount that `emptyDir` at `/var/lib/netbird/`. Source the setup key from a `Secret`. Pair this with the liveness, readiness, and startup probes from [Deploy routing peers to a Kubernetes cluster](/use-cases/kubernetes/routing-peers-and-kubernetes) for a complete deployment. ```yaml apiVersion: v1 @@ -89,8 +115,8 @@ metadata: data: default.json: | { - "ManagementURL": "https://api.netbird.io:443", - "AdminURL": "https://app.netbird.io:443", + "ManagementURL": { "Scheme": "https", "Host": "api.netbird.io:443" }, + "AdminURL": { "Scheme": "https", "Host": "app.netbird.io:443" }, "WgIface": "wt0", "BlockInbound": false, "BlockLANAccess": false, @@ -108,6 +134,15 @@ stringData: --- # Pod spec excerpt spec: + initContainers: + - name: seed-config + image: busybox:1.36 + command: ["sh", "-c", "cp /config/default.json /state/default.json"] + volumeMounts: + - name: config + mountPath: /config + - name: state + mountPath: /state containers: - name: netbird image: netbirdio/netbird:latest @@ -118,15 +153,22 @@ spec: name: netbird-setup-key key: NB_SETUP_KEY volumeMounts: - - name: config - mountPath: /var/lib/netbird/default.json - subPath: default.json + - name: state + mountPath: /var/lib/netbird volumes: - name: config configMap: name: netbird-config + - name: state + emptyDir: {} ``` + +An `emptyDir` lives and dies with the pod, so every new pod re-seeds from the `ConfigMap` and registers a fresh peer. That is the right behavior for a stateless workload: create the setup key with [Ephemeral peers](/manage/peers/register-machines-using-setup-keys#ephemeral-peers) enabled so the old entries are cleaned up. If you need a peer identity to survive restarts, use a `PersistentVolumeClaim` instead of the `emptyDir` and seed it only when `default.json` is absent. + + +Mounting the `ConfigMap` directly at `/var/lib/netbird/default.json` with `subPath` does not work: that is a read-only single-file mount, and it fails the same way as the Docker case above. + ## Backing up `default.json`