Self-hosted NetBird with the embedded IdP (and the bcrypt gotcha)

· 4 min read

I wanted a private network between my devices and the server: the box reachable over the mesh instead of the public internet, admin interfaces that do not need to be public, and a control plane I own. NetBird is WireGuard underneath, which is what I run elsewhere anyway, and its self-hosted server has become small enough to sit next to a portfolio site on a 4 GB VPS.

Topology

As of version 0.77 NetBird ships a combined server: management, signal, the relay and an embedded identity provider (Dex with local users) in one binary. No Zitadel, no Postgres, no separate IdP container. With the dashboard that is two containers and roughly 250 MB of RAM.

The parts that usually cost ports were the parts I wanted to avoid:

  • gRPC and the API go through Caddy on 443. Caddy speaks h2c to the backend, so the management and signal gRPC services work without exposing anything.
  • The relay is multiplexed onto the same port as a WebSocket stream (rels://vpn.<domain>:443), again through Caddy.
  • STUN is outsourced to public servers in the config, which disables the built-in STUN listener. Peers that cannot punch through NAT fall back to the relay over 443.

Net effect: the box still publishes exactly two ports, 80 and 443, both on Caddy.

The Caddy block is NetBird’s own example, verbatim apart from the hostnames:

vpn.divyam.top {
    import security_headers

    @grpc header Content-Type application/grpc*
    reverse_proxy @grpc h2c://netbird-server:80

    @backend path /relay* /ws-proxy/* /api/* /oauth2/*
    reverse_proxy @backend netbird-server:80

    reverse_proxy /* netbird-dashboard:80
}

Configuration as a template

The server takes a single config.yaml. Mine lives in the repository as a template with ${VAR} placeholders; a small bootstrap script generates the secrets (relay auth secret, datastore encryption key, session cookie key, the initial owner’s email and password) into a server-local env file on first run, renders the template into a server-local directory, and — this matters later — restarts the server only when the rendered file actually changed. The rendered file is never committed. The container mounts the directory containing it, not the file, for the reasons in the inode post.

Both services sit behind a compose profile, so the repository can carry them while they are switched off; enabling is one line in the server’s .env and a deploy.

First start

The first boot downloads two GeoLite databases before it listens, so for a minute Caddy answers 502 on the API paths while the dashboard (a static app) already loads. Gatus noticed before I did. After that: OIDC discovery at /oauth2/.well-known/openid-configuration, dashboard, 401 from /api/users without a token — all as expected.

Then I tried to log in.

The gotcha

The upstream config.yaml.example has this, commented out:

owner:
  email: "<owner email>"
  password: "initial-password"

I rendered the generated password into that field, and every login failed. The server log had the answer:

ERRO [err: parsing bcrypt hash: crypto/bcrypt: hashedSecret too short to be a bcrypted password]
     idp/dex/logrus_handler.go:83: failed to login user

The field is passed straight through to the embedded Dex as a bcrypt hashcombined/cmd/config.go assigns Owner.Password to the IdP’s Hash, and the IdP’s own type documents it as “the bcrypt hash of the user’s password (required)”. The example is misleading; the code is unambiguous.

The fix is to hash the password once when rendering the template. I used the caddy hash-password command from the Caddy image already on the box (any bcrypt tool works), and I cached the hash next to the other secrets — bcrypt salts are random, so re-hashing on every run would change the rendered config and restart the server on every deploy for no reason. Restart the server once and the login works with the plaintext password you generated.

hash=$(printf '%s\n' "$NETBIRD_ADMIN_PASSWORD" | docker run --rm -i caddy:2-alpine caddy hash-password)
# render: password: "$2a$14$..."

Two small things I would tell my past self: caddy hash-password reads a line from stdin, so the trailing newline matters; and under set -e a failed command substitution exits the script silently — log loudly around anything that can fail.

The client side

  • Mac: the NetBird app, Settings → Advanced → Management URL set to the self-hosted address, then Connect; the browser opens the dashboard’s login.
  • Phone: the NetBird app, Change server on the first screen, same URL, same login.
  • The server itself as a headless peer: create a setup key in the dashboard and run the Linux client with --management-url and --setup-key. From then on the box has a mesh address, and public SSH can go away.
  • Later: make the server an exit node (a 0.0.0.0/0 route with the box as the routing peer) so a phone on hotel Wi‑Fi rides through it.

It is a good feeling to have the control plane in the same repository as everything else — and a good reminder that “read the code, not the example” still applies to tools you like.