Amakuru.net

Moving Immich from a Mac to a single-node k3s cluster

Migrating an Immich install from docker-compose on a Mac to k3s on a homelab box, with bulk media on ZFS. Static hostPath PVs, a postgres deployed separately because the official chart deliberately doesn't bundle it, and a runbook designed to be readable by a fresh session.

Immich had been running on my Mac as a docker-compose dev setup for a while — convenient, but a Mac keeps doing Mac things (sleep, indexing, OS reboots) that aren’t ideal for “the place all the family photos live”. So: move it to the homelab. The target is a single-node k3s cluster on a small Linux box, with media on a ZFS pool. immich is the deployment repo and the source of truth for the migration, which is now complete (35,149 assets restored from the Mac dump, the Mac stack retired, the cluster is canonical).

k3s over docker-compose, even for one service

Single-node Kubernetes for one service looks like overkill, but I already run k3s on the box for other things, and the iteration loop with Helm is genuinely nicer than docker-compose once you’re past the learning curve — the values file is the spec, helm upgrade is the deploy, and config drift is a category that mostly stops existing.

Static hostPath PVs, not CSI

A single-node cluster doesn’t need a CSI driver. The photos live on a specific ZFS dataset on this exact host, and they aren’t going anywhere. Static hostPath PVs with a nodeAffinity constraint pinning them to the host make that relationship explicit — one PV each for upload, postgres, and the ML model cache, with the PVCs binding directly to them.

ZFS handles snapshots and redundancy underneath via zfs-auto-snapshot on the dataset (frequent / hourly / daily / weekly / monthly), so the backup story stays out of Kubernetes entirely.

Postgres lives outside the chart

The official immich/immich Helm chart deliberately does not bundle postgres; the chart expects you to bring your own database, and the production-grade answer is usually a Kubernetes operator like CloudNative-PG. For a single-node homelab that’s more machinery than the situation needs, so this deployment runs a single-replica Deployment of postgres in manifests/postgres.yaml, with the chart pods pointed at it via DB_HOSTNAME=immich-postgres in the values file.

The image matters. Immich uses vectorchord and pgvectors for similarity search, face clustering, and smart search — all postgres extensions that have to be present in the running database. The official ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0 image bakes them in; rolling your own postgres image to add them is technically possible and not worth doing.

/dev/shm is mounted as emptyDir: { medium: Memory, sizeLimit: 128Mi } — the Kubernetes equivalent of compose’s shm_size: 128mb. Postgres uses real shared memory for parallel queries, and the container runtime default isn’t large enough for the workload to run cleanly.

Secrets manually, with the password in Enpass

The original plan was sops + age. The deployment has one secret (DB_PASSWORD), one operator, and a password manager (Enpass) already holding the value, so the actual workflow is a single line:

kubectl -n immich create secret generic immich-secrets \
  --from-literal=DB_PASSWORD='<value from Enpass>'

Disaster recovery is the same line. There is no key recovery path to worry about, no chicken-and-egg between sops state and cluster state, and no extra service that has to keep running for the cluster to come back up. The committed .sops.yaml and the make secrets workflow are residual scaffolding from the original plan and aren’t used; sops, Sealed Secrets, and Vault would each have been one more piece of always-on machinery in a setup that didn’t have a second secret to justify it.

Image tag tracks release

The chart’s default tag at 0.11.1 resolves to Immich v2.6.3, and the Mac’s pg_dumpall was taken from v2.7.5. Mismatched schemas can let the app boot, run for a day, and then fail on the next migration cycle once a pod restarts — so the chart’s image tag is overridden to release (Immich’s rolling-latest) to keep the running app aligned with the dump. Pinning to a specific version is in docs/05-next-steps.md as an open TODO, and ZFS snapshots cover the meantime if a release ships broken.

Runbook

The docs/ folder leads with 00-context.md — current state, what remains, gotchas. The README points a fresh assistant session there first, and the same pointer works for me when I come back to the project after a gap, because by then I’m also a fresh session.

Was it worth it?

The Mac compose stack is retired, its disk kept as a cold backup. The cluster is the source of truth, the database is on its own static PV with the right extensions baked in, the photos are on ZFS, and the snapshots are running. Nothing in the setup is novel — it’s the smallest reasonable amount of machinery around stock Kubernetes primitives, with the deviations from the chart’s defaults documented in the runbook so the next pass at it doesn’t have to rediscover them.

dmorel69/immich — k3s, Helm, ZFS, postgres-with-vectorchord