# Migrating Authentik from Unraid to k3s A one-time, hand-run cutover: move the Authentik database off Unraid onto the cluster's CloudNativePG instance, bring Authentik up on k3s from `src/authentik/`, verify it, then change DNS. This is deliberately not automated. It runs once, most of its steps are irreversible in the wrong order, and the verification between them is a judgement call rather than a task result — the sort of thing a playbook makes harder rather than easier. The repo half *is* automated: everything from "deploy Authentik" onwards is `playbooks/k3s.yml`, and re-running it later is an ordinary converge. Read the whole thing before starting. The dump is taken with Authentik stopped, so there's a service outage from step 5 to step 11 — budget an hour and do it when nobody needs to log in. --- ## What moves, and what doesn't | | Where it lives now | How it gets across | |---|---|---| | Users, groups, applications, providers, flows, tokens, certificates | Postgres | `pg_dump` \| `psql` (step 7) | | Uploaded icons and flow backgrounds | `/media` on disk | `scp` + `kubectl cp` (step 9) | | Session cookies | signed by `AUTHENTIK_SECRET_KEY` | carry the key over (step 2) | | Cache / task queue | in-process | nothing — recent Authentik has no Redis, and the chart has no Redis dependency | Authentik stores its certificates (including the signing keys OIDC/SAML providers use) **in the database**, not in a `certs/` directory, so a successful database restore carries them. That is what lets integrations like Shelfarr's OIDC (`src/arr/`) keep working without being re-registered. The one piece with no automatic path is anything you mounted into the Unraid container by hand — custom templates, a `certs/` directory you populated yourself. Step 1 is where you find out whether you have any. --- ## 1. Gather the facts Everything below needs these. Run on the Unraid box (web terminal, or SSH). ```sh # Which containers make up the current deployment, and what image/tag. docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Status}}' | grep -i authentik ``` Write down the **image tag** — that is the Authentik version, and step 3 depends on it. ```sh # Every mount, so you can see where /media lives and spot anything unexpected. docker inspect authentik-server \ --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}' # The database connection it currently uses, and the secret key. docker inspect authentik-server \ --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -E 'POSTGRES|SECRET_KEY' ``` That last command tells you the thing this document can't assume: **which Postgres the Unraid Authentik talks to**. Two possibilities, and they change step 7: - `AUTHENTIK_POSTGRESQL__HOST=shared-postgres` (or `192.168.50.1`) — it uses the shared instance this repo already manages, `src/shared/postgres/`. The superuser password is in Vault at `homelab/shared/postgres`. - anything else (`authentik-postgresql`, `postgres`, a container name from Authentik's own compose bundle) — the Unraid UI deployment brought its own Postgres container. You'll need *its* credentials, which are in that container's own environment: ```sh docker inspect \ --format '{{range .Config.Env}}{{println .}}{{end}}' | grep POSTGRES ``` and it is probably not published on a host port, which step 7 handles. Also note `AUTHENTIK_SECRET_KEY` from the output above — step 2 needs it verbatim. --- ## 2. Populate Vault ```sh vault kv put kv/homelab/authentik \ AUTHENTIK_SECRET_KEY='' \ AUTHENTIK_POSTGRESQL__PASSWORD='' ``` Two different rules for those two values, and it's worth being clear about why: - **`AUTHENTIK_SECRET_KEY` must be the existing one.** It signs session cookies. A fresh value doesn't break anything permanently, but it invalidates every active session at the moment DNS flips — so instead of a silent cutover, everyone gets logged out and the migration announces itself. Copy it across. - **`AUTHENTIK_POSTGRESQL__PASSWORD` can be new.** You are creating a new role on a different Postgres; nothing in the dump references it. Generate a fresh one. If you're also using cert-manager's DNS-01 solver (step 4), populate its credentials now too: ```sh vault kv put kv/homelab/k3s-cert-manager \ CLOUDFLARE_API_TOKEN='' ``` --- ## 3. Confirm the repo's values Three values are guesses until you check them. All are flagged in-file. **`src/authentik/common/vars.yml`** - `AUTHENTIK_VERSION` — **set this to the image tag from step 1**, not to whatever is newest. Authentik runs Django migrations on startup against whatever schema it finds, and they are one-way: a newer Authentik pointed at an older dump upgrades the schema silently, and if the cutover then has to be rolled back, the Unraid instance can no longer read its own database. Migrate first, upgrade second, in a separate commit. - `AUTHENTIK_HOST` / `AUTHENTIK_URL` — the hostname you'll be moving. Must match what's there today, or every OIDC/SAML redirect URI registered in Authentik breaks. **`build/config/ansible/inventory/group_vars/k3s_cluster.yml`** - `k3s_cert_manager_solver` — empty by default, and `roles/k3s_cert_manager` refuses to run until it's set. The commented Cloudflare example in that file is the common case; for anything else take the stanza from [cert-manager's DNS-01 docs](https://cert-manager.io/docs/configuration/acme/dns01/). While you're testing, point `k3s_cert_manager_acme_server` at Let's Encrypt staging. Production allows five failed validations per hostname per hour, and exhausting it means waiting rather than retrying. --- ## 4. Deploy cert-manager and confirm the issuer works ```sh cd build/config/ansible ansible-playbook playbooks/k3s.yml --tags cert-manager ``` Then check it actually registered — a `ClusterIssuer` is created whether or not its solver is valid, so "the resource exists" proves nothing: ```powershell $env:KUBECONFIG = "$PWD\local\k3s\homelab-utils.kubeconfig" kubectl get clusterissuer letsencrypt -o jsonpath='{.status.conditions[*].message}' ``` You want `The ACME account was registered with the ACME server`. Anything else, fix it here — not after Authentik is deployed and waiting on a certificate. Worth knowing: with DNS-01, the certificate can be issued **before** DNS points at the cluster. The challenge is a TXT record, not a request to your web server, so cert-manager never needs to be reachable. That's why TLS is sorted out in this step and not after the flip. --- ## 5. Freeze the Unraid instance Stop writes before dumping. Skipping this gives you a dump that's missing whatever happened during it — usually invisible, occasionally a user account that no longer exists. ```sh docker stop authentik-server authentik-worker ``` Leave its Postgres running — step 7 reads from it. **The outage starts here.** --- ## 6. Create the role and database on CNPG The restore needs the `authentik` role to exist first so the dump's objects land with the right ownership. ```powershell $env:KUBECONFIG = "$PWD\local\k3s\homelab-utils.kubeconfig" kubectl -n shared-postgres exec -i shared-postgres-1 -- psql -v ON_ERROR_STOP=1 <<'SQL' CREATE ROLE authentik LOGIN PASSWORD 'the-password-from-step-2'; CREATE DATABASE authentik OWNER authentik; SQL ``` `kubectl exec` into a CNPG pod lands you as the `postgres` superuser over the local socket, so bare `psql` needs no credentials. You could instead let `roles/k3s_app` create these — its tasks are idempotent and will run in step 8 regardless — but doing it by hand here keeps the database creation and the restore adjacent, and avoids deploying Authentik against an empty database first (which would have it run initial migrations and build a schema the restore then has to fight). --- ## 7. Dump and restore Run the whole thing **inside the CNPG pod**, piping `pg_dump` straight into `psql`. Three reasons this is better than dumping to a file on Windows: - `pg_dump` must be at least the version of the server being dumped, and the CNPG pod ships Postgres 18 client tools — dumping the Unraid Postgres 13 from there is the supported direction. Your workstation probably has no `pg_dump` at all. - PowerShell re-encodes bytes passing through `>` and `|`, which silently corrupts a dump file. Nothing on the Windows filesystem means nothing to corrupt. - No intermediate file holding the entire user database in cleartext. ### If Authentik used the shared Postgres (published on `192.168.50.1:5432`) ```powershell $env:KUBECONFIG = "$PWD\local\k3s\homelab-utils.kubeconfig" $srcPw = Read-Host 'Unraid postgres superuser password' kubectl -n shared-postgres exec -i shared-postgres-1 -- ` env "PGPASSWORD=$srcPw" bash -c @' set -euo pipefail { echo "SET ROLE authentik;" pg_dump -h 192.168.50.1 -p 5432 -U postgres -d authentik \ --no-owner --no-privileges } | psql -v ON_ERROR_STOP=1 -d authentik '@ ``` `SET ROLE authentik` before the dump stream, combined with `--no-owner`, is what gets ownership right: the session creates every object as `authentik`, so the application role owns its own schema. Without it, everything lands owned by `postgres` and Authentik can read but not migrate. `ON_ERROR_STOP=1` matters — without it `psql` reports success having skipped every statement that failed. ### If it used its own Postgres container (not published) Publish it temporarily, then use the command above with the container's own credentials and port: ```sh # on Unraid docker run -d --rm --name pgbridge -p 55432:5432 \ --network container: alpine/socat \ TCP-LISTEN:5432,fork TCP:127.0.0.1:5432 ``` then `-h 192.168.50.1 -p 55432 -U ` in the `pg_dump` above, and `docker stop pgbridge` afterwards. Simpler alternative if you'd rather not: `docker exec pg_dump ... > /mnt/user/appdata/authentik.sql` on Unraid, `scp` it to your workstation, and `kubectl exec -i ... -- psql -d authentik < authentik.sql` — but a plain-SQL file through PowerShell redirection is exactly the encoding hazard noted above, so run that last command from WSL. ### Check the restore ```powershell kubectl -n shared-postgres exec -i shared-postgres-1 -- ` psql -d authentik -c '\dt' -c 'SELECT count(*) FROM authentik_core_user;' ``` You should see a long table list and a user count matching what the old instance had. An empty table list means the restore did nothing. --- ## 8. Deploy Authentik on k3s ```sh cd build/config/ansible ansible-playbook playbooks/k3s.yml --tags apps ``` A green run means the manifests landed and the database was provisioned — not that the workload came up. Watch it actually start: ```powershell kubectl -n authentik get pods -w ``` The first start takes several minutes on a Pi: pulling the image, then the worker checking migrations. Because the deployed version matches the dumped version (step 3), it should find nothing to migrate. If pods stay `Pending`, check the PVC — `local-path` uses `WaitForFirstConsumer`, so a `Pending` PVC alongside a `Pending` pod is normal only until the scheduler picks a node: ```powershell kubectl -n authentik describe pvc authentik-media kubectl -n authentik logs -l app.kubernetes.io/component=worker --tail=50 ``` --- ## 9. Copy `/media` across Two hops rather than a pipe, again to keep binary data out of a PowerShell pipeline: ```powershell # from the media path you noted in step 1 scp -r root@192.168.50.1:/mnt/user/appdata/authentik/media ./local/authentik-media $pod = kubectl -n authentik get pod -l app.kubernetes.io/component=server ` -o jsonpath='{.items[0].metadata.name}' kubectl -n authentik cp ./local/authentik-media/. "${pod}:/media" ``` `local/` is already gitignored, so the staging copy won't be committed. Skip this entirely if you never uploaded custom icons or backgrounds — Authentik falls back to its built-in assets, and the directory is empty on a stock install. --- ## 10. Verify before touching DNS The hostname still resolves to Unraid, so test by overriding resolution rather than changing it. `--resolve` sends the request to Traefik with the right SNI and `Host` header: ```powershell curl.exe -sv --resolve auth.turtlesystems.uk:443:192.168.50.80 ` https://auth.turtlesystems.uk/-/health/ready/ ``` Check, in order: 1. The TLS certificate is the Let's Encrypt one, not Traefik's self-signed default (`kubectl -n authentik get certificate` → `READY True`). If you used the staging server in step 3, it will be untrusted — expected; switch to production and re-run `--tags cert-manager` once satisfied. 2. `/-/health/ready/` returns 204. 3. Add a hosts-file entry pointing the name at `192.168.50.80` and log in through a browser with a **real user account**. This is the check that matters — it exercises the restored password hashes, the flows, and the session cookie signed with the carried-over secret key. 4. Open an existing application from the Authentik dashboard and confirm the OIDC/SAML round-trip still works (Shelfarr is the one this repo knows about — see `src/arr/`). Remove the hosts-file entry afterwards. --- ## 11. Flip DNS Point `auth.turtlesystems.uk` at `192.168.50.80` (Traefik's MetalLB address). Lower the record's TTL a few hours beforehand if you can — it shortens the window in which a rollback is still invisible to clients. --- ## Rollback Viable until users start making changes in the new instance; after that, rolling back loses whatever they did. 1. Point DNS back at Unraid. 2. `docker start authentik-server authentik-worker`. The old database was only ever read, never written, so it's exactly as it was at step 5. Nothing to restore. Do **not** run both instances at once against their separate databases — they diverge immediately, and there is no way to merge them afterwards. That is the same "never declare an app on two platforms" rule from CLAUDE.md, and it is why `src/authentik/ansible/unraid/` is a README rather than a compose file. --- ## After it has settled Once you're confident, in this order: 1. **Delete the Unraid containers.** A UI action, not `state: absent` — this repo never deployed them, so it has nothing to tear down. Keep the appdata directory a while longer. 2. **Drop the old database**, if it was on the shared Postgres: ```sh docker exec -it shared-postgres psql -U postgres -c 'DROP DATABASE authentik;' ``` Take a copy first if you want one; nothing else does. 3. **Upgrade Authentik**, now as an ordinary change: bump `AUTHENTIK_VERSION` in `src/authentik/common/vars.yml`, read the release notes for anything between your version and the target, and `ansible-playbook playbooks/k3s.yml --tags apps -e only_apps=authentik`. Do this as its own commit, so a bad upgrade is one `git revert` away from the version you know worked. ## Backups, afterwards Worth stating plainly: the cluster's CNPG has **no backups configured** (CLAUDE.md → "Key decisions" — HA replication only). Before this migration, Authentik's database sat on Unraid, inside whatever covers `/mnt/user/appdata`. After it, the only redundancy is a streaming replica on another Pi, which protects against a dead SD card and not against a bad migration, a dropped table, or a mistake in this document. The gap this leaves is real and this cutover widens it. CNPG's answer is a `ScheduledBackup` to object storage or an NFS volume on the NAS; that's not built here yet, and it's the obvious next piece of work after Authentik is settled.