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.
Who is this guide for? This migration guide is for users who:
- Have an existing self-hosted deployment using an external IdP
- Want to move to the embedded Dex-based IdP for a simpler, self-contained authentication setup
Overview
Migrating to the embedded IdP also unlocks the Combined Container Setup migration, which consolidates management, signal, relay, and STUN into a single container. If you plan to simplify your deployment, complete this IdP migration first, then follow the combined container guide.
The migration tool does two things:
- Re-encodes user IDs in the database to include the external connector ID, so Dex can route returning users to the correct external provider.
- Generates a new
management.jsonthat replacesIdpManagerConfigwithEmbeddedIdPand 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
This guide assumes you're using Docker and everything is run in the same bash session, if you're using a different setup, adjust the commands accordingly.
Prerequisites
| Requirement | Details |
|---|---|
| NetBird version | v0.67.2 or later |
| Config access | You can read and write management.json |
| Server downtime | The management server must be stopped during migration |
| Backups | Back up your database and config before starting |
Flags and Environment variables
| Flag | Environment variable | Description | Expected format |
|---|---|---|---|
--domain | NETBIRD_DOMAIN | Domain for both dashboard and API | example.com |
--dashboard-url | NETBIRD_DASHBOARD_URL | Dashboard domain (will override --domain) | example.com or example.com:33073 or https://example.com |
--api-url | NETBIRD_API_URL | API domain (will override --domain) | example.com or example.com:33073 or https://example.com |
--config | NETBIRD_CONFIG_PATH | Path to management.json (required) | /path/to/management.json |
--datadir | NETBIRD_DATA_DIR | Override data directory from config (store.db path will be derived from this) | /path/to/datadir |
--idp-seed-info | NETBIRD_IDP_SEED_INFO | Base64-encoded connector JSON | base64-encoded JSON string |
--dry-run | NETBIRD_DRY_RUN | Preview changes without writing | true or false |
--force | NETBIRD_FORCE | Skip confirmation prompt | true or false |
--skip-config | NETBIRD_SKIP_CONFIG | Skip config generation (DB migration only) | true or false |
--skip-populate-user-info | NETBIRD_SKIP_POPULATE_USER_INFO | Skip populating user info (user id migration only) | true or false |
--log-level | NETBIRD_LOG_LEVEL | Log level: debug, info, warn, error (default "info") | debug, info, warn, error |
When to use --domain vs --dashboard-domain vs --api-domain:
- If you have a single domain for both dashboard and API, use --domain
- If you don't have a reverse proxy in front of dashboard and API, make sure you use the domain + port combination for each.
For example,
--dashboard-domain demo.netbird.ioand--api-domain demo.netbird.io:33073
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
Before starting the migration, it's also a good idea to log out of the dashboard, as you might get a "stale" token from the old IdP which can cause 401 errors.
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.
- 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"
}
}
Using Zitadel as an example, the JSON should have the following values:
- "issuer": is the root domain of your zitadel instance, make sure you don't have any trailing slashes (e.g. https://zitadel.example.com)
- "clientID" and "clientSecret": are the values you copy when creating the OAuth app
- Encode and store it in the
NETBIRD_IDP_SEED_INFOenvironment 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.
Do not skip this step. The migration modifies user IDs in the database. A manual backup is your only recovery path if something goes wrong.
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
If the env vars are not set, you can use the flags listed at the Flags and Environment variables section.
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:
IdpManagerConfigis removed.EmbeddedIdPis present with"Enabled": trueand your connector inStaticConnectors.
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
- OIDC discovery: Open
https://<your-domain>/oauth2/.well-known/openid-configuration— it should return valid JSON. - Dashboard login: Log in to the dashboard — you should be redirected through your external IdP as before.
- Data integrity: Check that peers are visible and policies are intact.
Use an incognito/private browser window or clear cookies for your first login. Stale tokens from the old IdP will fail validation.
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:
- Stop the management server:
docker compose stop management - Restore the database:
- SQLite (Docker volume):
sudo cp $NETBIRD_DATA_DIR/store.db.bak $NETBIRD_DATA_DIR/store.db - PostgreSQL: restore from your
pg_dumpbackup
- SQLite (Docker volume):
- Restore the config:
cp $NETBIRD_CONFIG_PATH.bak $NETBIRD_CONFIG_PATH - Revert any reverse proxy or dashboard env changes.
- Start the management server:
docker compose up -d management

