Migration Guide: External IdP to Embedded IdP

This guide walks through migrating a self-hosted NetBird deployment from an external identity provider to the embedded IdP introduced in v0.62.0.

Overview

The migration tool does two things:

  1. Re-encodes user IDs in the database to include the external connector ID, so Dex can route returning users to the correct external provider.
  2. Generates a new management.json that replaces IdpManagerConfig with EmbeddedIdP and updates OAuth2 endpoints to the embedded Dex issuer.

After migration, existing users keep logging in through the same external provider — Dex acts as a broker in front of it. No passwords or credentials change.


Before You Begin

Prerequisites

RequirementDetails
NetBird versionv0.67.2 or later
Config accessYou can read and write management.json
Server downtimeThe management server must be stopped during migration
BackupsBack up your database and config before starting

Flags and Environment variables

FlagEnvironment variableDescriptionExpected format
--domainNETBIRD_DOMAINDomain for both dashboard and APIexample.com
--dashboard-urlNETBIRD_DASHBOARD_URLDashboard domain (will override --domain)example.com or example.com:33073 or https://example.com
--api-urlNETBIRD_API_URLAPI domain (will override --domain)example.com or example.com:33073 or https://example.com
--configNETBIRD_CONFIG_PATHPath to management.json (required)/path/to/management.json
--datadirNETBIRD_DATA_DIROverride data directory from config (store.db path will be derived from this)/path/to/datadir
--idp-seed-infoNETBIRD_IDP_SEED_INFOBase64-encoded connector JSONbase64-encoded JSON string
--dry-runNETBIRD_DRY_RUNPreview changes without writingtrue or false
--forceNETBIRD_FORCESkip confirmation prompttrue or false
--skip-configNETBIRD_SKIP_CONFIGSkip config generation (DB migration only)true or false
--skip-populate-user-infoNETBIRD_SKIP_POPULATE_USER_INFOSkip populating user info (user id migration only)true or false
--log-levelNETBIRD_LOG_LEVELLog level: debug, info, warn, error (default "info")debug, info, warn, error

Step 1: Prepare your Management Server

Make sure your management server is on the latest version, otherwise management will not be able to properly parse the new management.json file generated by this migration tool.

docker compose pull
docker compose up -d management

Step 2: Get the Migration Tool

Option A — Download a pre-built binary:

# Replace VERSION with the release tag, and adjust the architecture as needed
curl -L -o netbird-idp-migrate.tar.gz \
  https://github.com/netbirdio/netbird/releases/download/v0.67.2/netbird-idp-migrate_0.67.2_linux_amd64.tar.gz
tar xzf netbird-idp-migrate.tar.gz
chmod +x netbird-idp-migrate

Available architectures: linux_amd64, linux_arm64, linux_arm.

Option B — Build from source (requires Go 1.25+ and a C compiler for CGO/SQLite):

go build -o netbird-idp-migrate ./tools/idp-migrate/

Copy the binary to the management server host if you built it elsewhere.


Step 3: Prepare Your Provider

The new embedded IdP made the generation of the OIDC connector config easier, that's why we recommend generating a new application for your provider.

When following the guides to generate the application, make sure you store the client ID and client Secret somewhere safe. You'll need them later.

To spare details in this guide, you can use the following guides to create the OIDC connector configuration for your provider:

Creating the IdP Seed info

With the client id and client secret from the previous step, you can create the idp-seed-info for the tool, which will be used to generate the OIDC connector config.

  1. We'll create a new file "connector.json" with the following contents, make sure you remember where you save it:
{
  "type": "oidc",
  "name": "My Provider",
  "id": "my-provider",
  "config": {
    "issuer": "https://idp.example.com",
    "clientID": "my-client-id",
    "clientSecret": "my-client-secret"
  }
}
  1. Encode and store it in the NETBIRD_IDP_SEED_INFO environment variable:
export NETBIRD_IDP_SEED_INFO=$(base64 < connector.json | tr -d '\n') 

Step 4: Stop the Management Server

docker compose stop management

Step 5: Back Up Your Data

The tool creates management.json.bak automatically, but always make your own backups.

Docker Compose (SQLite in a named volume):

# Identify the volume name
VOLUME_NAME=$(docker volume ls --format '{{ .Name }}' | grep -Ei 'management|mgmt')
echo "Volume: $VOLUME_NAME"

# Get the host path
export NETBIRD_DATA_DIR=$(docker volume inspect "$VOLUME_NAME" --format '{{ .Mountpoint }}')
echo "Path: $NETBIRD_DATA_DIR"

# (SQLite only) Verify store.db exists, then back up
sudo ls "$NETBIRD_DATA_DIR/store.db"
sudo cp "$NETBIRD_DATA_DIR/store.db" "$NETBIRD_DATA_DIR/store.db.bak"

# Verify management.json exists, the path will vary based on your setup, then back up
export NETBIRD_CONFIG_PATH="<path-to-config>/management.json"
cat "$NETBIRD_CONFIG_PATH"

cp "$NETBIRD_CONFIG_PATH" "$NETBIRD_CONFIG_PATH.bak"

Step 6: Run the Migration

Validate required env vars / flags

echo $NETBIRD_CONFIG_PATH
echo $NETBIRD_DATA_DIR
echo $NETBIRD_IDP_SEED_INFO | base64 -d

You should expect to see an output similar to this:

/etc/netbird/management.json
/var/lib/docker/volumes/management_data/_data
{
  "type": "oidc",
  "name": "my-provider",
  "id": "my-provider",
  "config": {
    "issuer": "https://idp.example.com",
    "clientID": "my-client-id",
    "clientSecret": "my-client-secret",
  }
}

(PostgreSQL only) Verify that the database env var is set

The postgres store engine requires the postgres container to expose the port over the host so that the migration tool can connect to it. You can set the env var in your shell:

# This should match the same env var content that is passed to the management server
export NB_STORE_ENGINE_POSTGRES_DSN="host=localhost port=5432 user=postgres password=postgres dbname=netbird sslmode=disable"

If you don't see the expected output, please make sure you followed the steps in this guide, or that you use the correct flags while running the tool.

Dry run (always do this first)

Assuming that you've followed the steps in this guide, you should be able to run the tool with the following command:

./netbird-idp-migrate --domain mgmt.example.com --dry-run

After running the dry run command you should see output like:

INFO resolved connector: type=oidc, id=auth0, name=auth0
INFO found 12 total users: 12 pending migration, 0 already migrated
INFO [DRY RUN] would migrate user abc123 -> CgZhYmMxMjMSB3ppdGFkZWw (account: acct-1)
...
INFO [DRY RUN] migration summary: 12 users would be migrated, 0 already migrated
INFO derived domain for embedded IdP: mgmt.example.com
INFO [DRY RUN] new management.json would be:
{ ... }

Verify before proceeding:

  • Connector type and ID match your provider.
  • User count matches what you expect.
  • Generated config has the correct domain and endpoints.

Execute the migration

Run the same command without --dry-run:

./netbird-idp-migrate --domain mgmt.example.com

The tool will show a summary and prompt for confirmation:

About to migrate 12 users. This cannot be easily undone. Continue? [y/N]

Type y and press Enter.

Review the new config

Your management.json should be significantly smaller now, and the OIDC connector config should be present in StaticConnectors. Make sure you verify the following:

  • IdpManagerConfig is removed.
  • EmbeddedIdP is present with "Enabled": true and your connector in StaticConnectors.

Step 7: Post-Migration Configuration

Update your reverse proxy

The embedded Dex IdP is served under /oauth2/. Your reverse proxy must route this path to the management server.

Caddy — add to your Caddyfile inside the site block for your management domain:

reverse_proxy /oauth2/* management:80

Place it alongside existing /api/* and /management.ManagementService/* routes, then reload:

docker compose restart caddy

Nginx:

location /oauth2/ {
    proxy_pass http://management:80;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Reload nginx after adding the route.

Traefik: Add a route matching the /oauth2/ path prefix, forwarding to the management service.

Verify the route works:

curl -s https://<your-domain>/oauth2/.well-known/openid-configuration | head -5

Expected: a JSON response with "issuer": "https://<your-domain>/oauth2".

Update dashboard environment

Your dashboard should also be updated to use the embedded IdP configuration, for this, make sure you update the dashboard.env or environment variables (check docker compose file for reference).

Your env vars should have the following values:

AUTH_AUDIENCE=netbird-dashboard
AUTH_CLIENT_ID=netbird-dashboard
AUTH_AUTHORITY=https://<your-domain>/oauth2
AUTH_SUPPORTED_SCOPES=openid profile email groups
AUTH_REDIRECT_URI=/nb-auth
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth

If you're using docker, make sure to recreate the dashboard container to apply the new environment variables with docker compose up -d dashboard


Step 8: Start and Verify

Start the management server

docker compose up -d management

Verify everything works

  1. OIDC discovery: Open https://<your-domain>/oauth2/.well-known/openid-configuration — it should return valid JSON.
  2. Dashboard login: Log in to the dashboard — you should be redirected through your external IdP as before.
  3. Data integrity: Check that peers are visible and policies are intact.

Troubleshooting

"store does not support migration operations"

The store implementation is missing the required ListUsers/UpdateUserID methods. Upgrade to v0.67.2+ binaries.

"could not open activity store"

This is a warning, not an error. If events.db doesn't exist (e.g., fresh install), activity event migration is skipped. User ID migration in the main database still proceeds normally.

"no connector configuration found"

No IdP configuration was detected. Provide it explicitly with --idp-seed-info, or set the IDP_SEED_INFO env var.

"Errors.App.NotFound" from Zitadel after migration

The dashboard is still redirecting to Zitadel's /oauth/v2/ endpoint instead of the management server's /oauth2 endpoint. Set AUTH_AUTHORITY=https://<your-domain>/oauth2 in your dashboard environment — see Update dashboard environment.

OIDC discovery returns 404

The /oauth2/ path is not being routed to the management server. Add a reverse proxy route — see Update your reverse proxy.

"jumpcloud does not have a supported Dex connector type"

JumpCloud has no native Dex connector. Configure a generic OIDC connector manually with --idp-seed-info — see Other Providers in Step 3.

"failed to create embedded IDP service: cannot disable local authentication..."

The embedded IdP didn't support StaticConnectors in this config version. Upgrade to v0.67.2+ which includes this fix.

Partial failure / re-running

The migration is idempotent. Already-migrated users are detected and skipped. If the tool fails partway through, fix the underlying issue and re-run — it picks up where it left off.


Rolling Back

If something goes wrong after migration:

  1. Stop the management server: docker compose stop management
  2. Restore the database:
    • SQLite (Docker volume): sudo cp $NETBIRD_DATA_DIR/store.db.bak $NETBIRD_DATA_DIR/store.db
    • PostgreSQL: restore from your pg_dump backup
  3. Restore the config: cp $NETBIRD_CONFIG_PATH.bak $NETBIRD_CONFIG_PATH
  4. Revert any reverse proxy or dashboard env changes.
  5. Start the management server: docker compose up -d management