diff --git a/src/components/NavigationDocs.jsx b/src/components/NavigationDocs.jsx
index ca7f055f..9fe51754 100644
--- a/src/components/NavigationDocs.jsx
+++ b/src/components/NavigationDocs.jsx
@@ -644,6 +644,10 @@ export const docsNavigation = [
title: 'Enable Reverse Proxy',
href: '/selfhosted/migration/enable-reverse-proxy',
},
+ {
+ title: 'External IdP to Embedded IdP',
+ href: '/selfhosted/migration/external-to-embedded-idp',
+ },
],
},
],
diff --git a/src/pages/selfhosted/migration/combined-container.mdx b/src/pages/selfhosted/migration/combined-container.mdx
index 6295627c..e5d21cd3 100644
--- a/src/pages/selfhosted/migration/combined-container.mdx
+++ b/src/pages/selfhosted/migration/combined-container.mdx
@@ -14,7 +14,7 @@ This guide walks you through migrating a pre-v0.65.0 NetBird self-hosted deploym
-The migration script **exits with an error** if it detects an external identity provider (Auth0, Keycloak, Okta, Zitadel, Google Workspace, Microsoft Entra ID, etc.). If you are using an external IdP, do not run this script. Instead, follow the [self-hosting quickstart](/selfhosted/selfhosted-quickstart) for a fresh installation.
+The migration script **exits with an error** if it detects an external identity provider (Auth0, Keycloak, Okta, Zitadel, Google Workspace, Microsoft Entra ID, etc.). If you are using an external IdP, first follow the [External IdP to Embedded IdP migration guide](/selfhosted/migration/external-to-embedded-idp) to switch to the embedded Dex IdP, then return here to complete the combined container migration.
## Overview of changes
diff --git a/src/pages/selfhosted/migration/external-to-embedded-idp.mdx b/src/pages/selfhosted/migration/external-to-embedded-idp.mdx
new file mode 100644
index 00000000..f8e223c3
--- /dev/null
+++ b/src/pages/selfhosted/migration/external-to-embedded-idp.mdx
@@ -0,0 +1,425 @@
+import {Note, Warning, Success} from "@/components/mdx"
+
+export const description = 'Migrate a self-hosted NetBird deployment from an external identity provider to the embedded IdP introduced in v0.62.0.'
+
+# 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](/selfhosted/migration/combined-container), 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:
+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
+
+
+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.io` and `--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.
+
+```bash
+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:**
+
+```bash
+# 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):
+
+```bash
+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:
+- Auth0
+- Azure AD
+- Keycloak
+- Okta
+- Authentik
+- PocketID
+- Google Workspace
+- JumpCloud
+- Zitadel
+
+
+### 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:
+```json
+{
+ "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
+
+
+
+2. Encode and store it in the `NETBIRD_IDP_SEED_INFO` environment variable:
+
+```bash
+export NETBIRD_IDP_SEED_INFO=$(base64 < connector.json | tr -d '\n')
+```
+
+---
+
+## Step 4: Stop the Management Server
+
+```bash
+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):**
+
+```bash
+# 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="/management.json"
+cat "$NETBIRD_CONFIG_PATH"
+
+cp "$NETBIRD_CONFIG_PATH" "$NETBIRD_CONFIG_PATH.bak"
+```
+---
+
+## Step 6: Run the Migration
+
+### Validate required env vars / flags
+
+```bash
+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:
+
+```bash
+# 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:
+
+```bash
+./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](#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`:
+
+```bash
+./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:
+
+```bash
+docker compose restart caddy
+```
+
+**Nginx:**
+
+```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:**
+
+```bash
+curl -s https:///oauth2/.well-known/openid-configuration | head -5
+```
+
+Expected: a JSON response with `"issuer": "https:///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:
+```bash
+AUTH_AUDIENCE=netbird-dashboard
+AUTH_CLIENT_ID=netbird-dashboard
+AUTH_AUTHORITY=https:///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
+
+```bash
+docker compose up -d management
+```
+
+### Verify everything works
+
+1. **OIDC discovery:** Open `https:///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.
+
+
+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:///oauth2` in your dashboard environment — see [Update dashboard environment](#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](#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](#other-providers-auth0-azure-ad-keycloak-okta-authentik-pocketid-google-jumpcloud-etc) 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`