10 KiB
Immich Migration Guide
This document describes how to migrate Immich from the existing Docker Compose stack (docker/stacks/immich/) to this Kubernetes deployment.
Overview
The migration involves three categories of data:
- PostgreSQL database — all Immich metadata, users, albums, face recognition data, search vectors
- Photo library — already on Synology NAS via NFS, no data movement needed
- Ephemeral state (ML model cache, Redis/Valkey) — will be recreated automatically
Only the PostgreSQL database requires active migration. Everything else either stays in place (photos) or regenerates on first start (model cache, Valkey).
What Changes
| Aspect | Docker Compose | Kubernetes |
|---|---|---|
| Orchestration | Docker Compose via Portainer | Helm chart via Flux CD HelmRelease |
| PostgreSQL | Container with bind mount | StatefulSet with NFS-backed PVC |
| Redis | Standalone Redis 6.2 | Valkey (Redis fork) via Helm subchart |
| Backups (photos) | resticprofile with crond inside container | K8s CronJob running resticprofile |
| Backups (database) | postgres-backup-local (hourly local dumps) | K8s CronJob: pg_dump + rclone to R2 |
| Ingress | Traefik Docker labels | K8s Ingress with cert-manager TLS |
| Secrets | stack.env.real (plain text on disk) |
SOPS-encrypted Secrets in Git |
| Network isolation | Docker network immich |
K8s NetworkPolicies (default-deny + explicit allow) |
| Authentication | Immich built-in | Immich built-in (unchanged, no Authelia) |
What Stays the Same
- Photo library location on NAS (same NFS path)
- Immich PostgreSQL image with vectorchord/pgvectors extensions
- Restic backup repository on Backblaze B2 (same repo, same key — history carries over)
- Database credentials (can be reused from
stack.env.real)
Prerequisites
-
kubectlconfigured for the target cluster -
sopsandageinstalled for encrypting secrets -
Docker Compose stack still running (for database dump)
-
Note the following values from your Docker
stack.env.real:UPLOAD_LOCATION— photo library path on NAS (this becomesIMMICH_UPLOAD_NFS_PATH)DB_PASSWORD,DB_USERNAME,DB_DATABASE_NAME— current database credentials
-
Verify which Immich version Docker is running:
docker inspect immich-server --format '{{.Config.Image}}'The HelmRelease pins
v2.0.0. If Docker runs a different version, updateimage.taginrelease.yamlto match before migrating, then upgrade after migration is verified.
Phase 1: Deploy Infrastructure (Immich Suspended)
The HelmRelease is deployed in a suspended state so that only the supporting infrastructure (database, PVCs, secrets, network policies) is created. Immich app pods will not start until Phase 3.
-
Suspend the HelmRelease by adding
spec.suspend: truetorelease.yaml:spec: suspend: true chart: ... -
Fill in and encrypt all secrets:
# Edit with actual values, then encrypt sops --encrypt --in-place kubernetes/app/immich/secret.sops.yaml sops --encrypt --in-place kubernetes/app/immich/secret-rclone.sops.yaml sops --encrypt --in-place kubernetes/app/immich/secret-backup.sops.yaml -
Set
IMMICH_HOSTandIMMICH_UPLOAD_NFS_PATHin cluster-vars:sops kubernetes/config/cluster-vars.sops.yamlIMMICH_UPLOAD_NFS_PATHmust be the same NAS path as Docker'sUPLOAD_LOCATION. -
Commit and push:
git add kubernetes/app/immich/ kubernetes/config/cluster-vars.sops.yaml git commit -m "feat(k8s/immich): add immich stack (HelmRelease suspended for migration)" git push -
Wait for Flux to reconcile:
flux reconcile kustomization apps --with-source kubectl get pvc -n immichAll PVCs should be
Bound. The HelmRelease will show asSuspended. -
Verify the database pod is ready:
kubectl wait --for=condition=ready pod -n immich -l app=immich-db --timeout=120s
Phase 2: Migrate PostgreSQL Database
The StatefulSet is running from Phase 1 with an empty database. Dump from Docker and restore into K8s.
-
Dump the database from the running Docker container:
docker exec immich-database pg_dump -U immich -d immich --clean --if-exists > immich.sqlThe container name may differ — check with
docker ps | grep postgres. Use the container running theghcr.io/immich-app/postgresimage. -
Verify the dump is non-empty:
wc -l immich.sql grep -c "CREATE TABLE" immich.sql -
Copy the dump into the K8s pod and restore:
kubectl cp immich.sql immich/immich-db-0:/tmp/immich.sql kubectl exec -n immich immich-db-0 -- psql -U immich -d immich -f /tmp/immich.sqlSome
DROP ... does not existnotices are expected (from--clean --if-exists). Errors about extensions already existing are also normal. -
Verify restoration:
# Check table count kubectl exec -n immich immich-db-0 -- psql -U immich -d immich -c \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';" # Check that vector extensions are present kubectl exec -n immich immich-db-0 -- psql -U immich -d immich -c \ "SELECT extname, extversion FROM pg_extension WHERE extname LIKE '%vector%';" # Check asset count (your photo count) kubectl exec -n immich immich-db-0 -- psql -U immich -d immich -c \ "SELECT count(*) FROM asset;" -
Verify the NFS library path is correct. The photo library PV should point to the same NFS directory as Docker's
UPLOAD_LOCATION:kubectl get pv immich-library -o jsonpath='{.spec.nfs.path}'
Phase 3: Start Immich
With the database restored and NFS path verified, unsuspend the HelmRelease.
-
Remove
suspend: truefromrelease.yaml. -
Commit and push:
git add kubernetes/app/immich/release.yaml git commit -m "feat(k8s/immich): unsuspend HelmRelease after data migration" git push -
Wait for Flux to deploy the Helm chart:
flux reconcile kustomization apps --with-source kubectl get helmrelease -n flux-system immich kubectl get pods -n immich -wYou should see pods for:
immich-server,immich-machine-learning,immich-valkey-master, andimmich-db-0(already running). -
The ML service will download models on first start — this is expected and may take several minutes.
Post-Migration Verification
Web Access
Open https://<IMMICH_HOST> in a browser. You should see the Immich login page with your existing users.
Photo Library
- Log in and verify your photos and albums are visible
- Check that thumbnails load (they're stored in the upload directory)
- Verify face recognition data is intact (People tab)
Ingress and TLS
kubectl get ingress -n immich
kubectl get certificate -n immich
The certificate should show Ready: True after cert-manager provisions it.
Backup CronJobs
Trigger manual test runs:
# Test database backup
kubectl create job -n immich --from=cronjob/immich-db-backup immich-db-backup-test
kubectl logs -n immich -l job-name=immich-db-backup-test -f
# Test library backup (resticprofile)
kubectl create job -n immich --from=cronjob/immich-library-backup immich-library-backup-test
kubectl logs -n immich -l job-name=immich-library-backup-test -f
The library backup should connect to the existing Backblaze B2 restic repository and complete an incremental backup.
Network Policies
# Verify immich-server can reach the database
kubectl exec -n immich deploy/immich-server -- nc -z immich-db 5432
Stop Docker Compose Stack
Only after K8s is verified working:
In Portainer: Stop the immich stack.
Or via CLI:
docker compose -f docker-compose.yaml --env-file stack.env.real down
The photo library on the NAS remains accessible via the K8s NFS PV.
Rollback
The Docker data is not modified during migration (pg_dump reads only, NFS path is shared). To roll back:
-
Revert the immich manifests from Git and push. Flux will delete the namespace and all resources via pruning.
Alternatively, suspend Flux and delete manually:
flux suspend kustomization apps kubectl delete namespace immich -
Re-deploy the Docker Compose stack from Portainer with the original
stack.env.real.
Pitfalls and Troubleshooting
PostgreSQL PGDATA path difference
Docker uses PGDATA=/var/lib/postgresql/data (the mount root). The K8s StatefulSet sets PGDATA=/var/lib/postgresql/data/pgdata (a subdirectory). Never copy the Docker PostgreSQL data directory directly to the K8s PVC. Always use pg_dump/psql.
Immich version mismatch
If Docker was running a different Immich version than the one pinned in the HelmRelease (v2.0.0), Immich may run database migrations on startup. This is normally fine (Immich handles forward migrations), but there is no rollback path for schema changes. Match versions first if unsure.
NFS path mismatch
IMMICH_UPLOAD_NFS_PATH must resolve to exactly the same NAS directory as Docker's UPLOAD_LOCATION. If it differs, Immich will start but show no photos, and thumbnails will be broken. Verify by checking inside a running pod:
kubectl exec -n immich deploy/immich-server -- ls /usr/src/app/upload/
You should see directories like library/, thumbs/, encoded-video/, upload/, profile/.
vectorchord/pgvectors extensions
The K8s StatefulSet uses the same custom PostgreSQL image (ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0) as Docker. Extensions are installed during initdb and preserved through pg_dump/psql restore. If you see errors about missing vector type, verify the extensions:
kubectl exec -n immich immich-db-0 -- psql -U immich -d immich -c "SELECT extname FROM pg_extension;"
Restic lock errors
If the Docker resticprofile container was not cleanly stopped, a stale lock may exist in the Backblaze B2 restic repository. The K8s CronJob will fail with a lock error. Fix by running:
kubectl create job -n immich --from=cronjob/immich-library-backup immich-unlock-test --dry-run=client -o yaml | \
sed 's/backup && resticprofile.*forget/unlock/' | kubectl apply -f -
Or exec into a temporary pod with the restic key and AWS credentials and run restic unlock.