Skip to main content

Immich

Immich is a self-hosted photo and video backup. Every instance in this cluster is an Argo CD Application: the upstream OCI Helm chart, a slim private overlay, and companion manifests for PVCs, Postgres, Redis, and NetworkPolicy. Day-2 kubectl apply, helm upgrade, and old deploy scripts are not the upgrade path.

GitOps model
#

PieceWhereArgo role
Application CRhomelabs-private/.../argocd/applications/immich-<instance>.yamlMulti-source app
Shared Helm valueshomelabs/immich/values.yamlOnly public file Argo Helm consumes (valueFiles)
Instance overlay.../overlays/immich-<instance>/values.yamlLibrary existingClaim + existingConfiguration only
Secrets / Immich config.../manifests/immich-<instance>/externalsecret.yamlESO → db / app / immich-config Secrets (Vault)
PVC / Postgres / Redis.../manifests/immich-<instance>/ (kustomization.yaml)Sync-wave -1 companions
Public companion shapeshomelabs/immich/ (pvc.yaml, redis.yaml, postgres.yaml, networkpolicy.yaml, manifests/externalsecret.yaml)Copy/adapt into private manifests, not applied directly by Argo from homelabs
Landing CSS / messagehomelabs-private/.../manifests/immich-<instance>/landing-theme.css (+ landing-message.html)Private SoT; snapshot into ExternalSecret theme.customCss / server.loginPageMessage (Auth landing brand)

Redis runs as a standalone companion Deployment, not the chart’s Valkey: shared values set valkey.enabled: false and REDIS_HOSTNAME: <release>-redis to match the companion Service. Private redis.yaml mounts a dynamic longhorn-ssd claim (<release>-redis-pvc-lh, 2Gi) and uses strategy: Recreate so a single RWO volume can remount after a pod replace. Secrets come only from Vault through the External Secrets Operator (OCI chart 0.13.x with configurationKind: Secret and immich.existingConfiguration); never commit Helm secret values files or plaintext Immich secrets to git.

The public tree no longer ships strategic-merge patch.yaml, secrets.yaml.template, or instances/*/instance.env.template. External-import photos are wired only through Helm server.persistence.photos in values.yaml (read-only /mnt/photos).

Storage volumes
#

Each instance mounts four companion PersistentVolumeClaims (shapes in homelabs/immich/pvc.yaml, copied into private manifests) plus an ephemeral ML cache that is not a claim.

VolumePurposeServer / app may write?Typical attach
libraryImmich upload library (mounted at /data)YesShared NFS (nfs-csi, RWX)
photosExternal-import tree (mounted at /mnt/photos)No, read-onlyShared NFS (nfs-csi, ReadOnlyMany)
ml-cacheMachine-learning model cacheYesemptyDir, 4Gi sizeLimit (node disk, not Memory)
redisRedis AOF persistenceYeslonghorn-ssd RWO, 2Gi (*-redis-pvc-lh)
postgresqlPostgres dataYeslonghorn-ssd RWO, 10Gi

Keep photos read-only in both the PVC access mode (ReadOnlyMany) and the Helm mount (server.persistence.photos/mnt/photos with readOnly: true). That volume is for Immich external libraries over an existing NAS share. Leave Bound production claims alone unless you are intentionally recreating storage. Access modes on live PVCs are effectively frozen.

Redis is a small chatty RWO volume on longhorn-ssd. Recreate that claim empty when you wipe the catalog; do not copy an old NFS AOF onto Longhorn. Machine-learning cache is type: emptyDir with sizeLimit: 4Gi in shared homelabs/immich/values.yaml. A leftover existingClaim on that key still binds a PVC and skips emptyDir. The directory uses the node’s disk (Kubernetes default), not medium: Memory; weights re-download after a reschedule or node reboot.

Change image tags, OAuth, Redis, or PVC in git, then let Argo sync. Do not kubectl apply -f redis.yaml by hand.

NFS base path
#

Site inventory for each instance lives under homelabs-private/clusters/<cluster>/config/cluster.yaml at immich.instances.<name>.nfs (server, base_path). That file is inventory only. Argo does not template PVs from it. Private manifests under manifests/immich-<instance>/pvc.yaml set each NFS PV csi.volumeAttributes.share to a subdirectory of that base:

<nfs-base-path>/library
<nfs-base-path>/photos

Those are the only Immich NFS trees. Redis and the ML cache are not on the NAS, so there is nothing to create at redis/, ml-cache/, or postgres/ under the instance base. On the NAS, create library and photos before the first sync. PV server is <nfs-server> (the host that exports the share).

Public companion shapes use placeholders ${NFS_SERVER_IP} and ${NFS_BASE_PATH}; replace them when copying into private manifests.

Synology NFS permissions
#

Immich NFS PVs expect NFSv3 with nolock. On DSM, enable the NFS service (NFSv3) under Control Panel → File Services → NFS, then set permissions on the shared folder that backs <nfs-base-path> (Shared Folder → Edit → NFS Permissions).

A working rule set for k3s nodes is Privilege Read/Write, Squash No mapping, Security sys, Enable asynchronous on, and Allow users to access mounted subfolders on. Allow each node IP (or a trusted LAN CIDR) that may schedule Immich pods. Access denied on mount usually means the client IP is missing from the NFS ACL, not a Kubernetes bug. For node-join NFS ACL steps in the Proxmox/k3s path, see Infrastructure installation.

Change the NFS share path
#

Static PVs with Retain do not retarget when you edit share in git alone: Bound claims stay tied to the existing PV object. A path change only moves the library and photos trees. Redis and Postgres stay on longhorn-ssd; the ML cache is emptyDir and has no NFS object to retarget.

  1. Update inventory (cluster.yaml) and private pvc.yaml shares to <nfs-base-path>/{library,photos}. Create those two directories on the NAS and confirm NFS ACLs cover every node.
  2. Scale Immich workloads down (server, machine-learning, redis; leave Postgres alone unless you are also wiping the database).
  3. Delete the NFS PVCs and their Bound static PVs for that instance (library, photos only). Do not delete the Postgres or Redis Longhorn PVCs unless you intend a catalog or queue wipe.
  4. Commit the private manifest changes, then Argo sync so companions recreate PVs/PVCs at the new path.
  5. Scale workloads back up (or let the Application restore replicas on sync).

Kubernetes does not move NAS data. Copy or leave files on the old path yourself if you still need them. After a path change with an intact Postgres catalog, Immich expects the upload library and related trees to exist at the new shares; otherwise treat it as a rebuild (below).

Fresh rebuild (database wiped)
#

If Postgres is emptied or restored empty, the old upload library on NFS does not auto-reattach as Immich’s primary library; the catalog rows are gone. Before you wipe anything, check Immich’s own SQL dumps under the upload location (UPLOAD_LOCATION/backups/*.sql.gz on the library volume) if you intend to restore the catalog.

Practical defaults when rebuilding from scratch: the ML cache is emptyDir, so models re-download after any pod reschedule (including the first boot of an empty database). Recreate the Redis longhorn-ssd claim empty so you do not reuse stale queue data against a new catalog. The companion Deployment’s Recreate strategy is what lets that RWO volume remount. Old library files may remain on NFS; re-import via External Library (read-only /mnt/photos) or CLI upload, or restore from a SQL backup first. Keep photos as the external-import tree and wire External Libraries in Immich admin to paths under /mnt/photos.

Deploy a new instance
#

Cloning an existing instance is the usual path: copy private manifests and the overlay, rename the instance, then wire Vault, AppProject, namespace, and public hostname before the first sync.

  1. Choose <nfs-server> and <nfs-base-path>; record them under immich.instances.<name>.nfs in clusters/<cluster>/config/cluster.yaml. On the NAS, create only library/ and photos/ under that base. Postgres uses a dynamic longhorn-ssd claim (<release>-postgresql-pvc-lh, 10Gi) and Redis uses <release>-redis-pvc-lh (2Gi, Recreate). Machine-learning cache is emptyDir; do not create NFS postgres/, redis/, or ml-cache/ directories.
  2. Confirm Synology NFS permissions for the shared folder (see above) include every node that may schedule pods.
  3. Copy companion shapes from homelabs/immich/ (or clone another private instance tree) into homelabs-private/clusters/<cluster>/manifests/immich-<instance>/ (pvc.yaml, postgres.yaml / postgresql.yaml, redis.yaml, networkpolicy.yaml, ExternalSecret, kustomization.yaml). Replace ${NFS_SERVER_IP}, ${NFS_BASE_PATH}, ${INSTANCE_NAME}, ${NAMESPACE}, ${LAN_CIDR}, ${POD_CIDR}, and public hostname in the Immich config template. Do not copy obsolete bootstrap files; there is no patch.yaml or secret template in the public tree.
  4. Create the destination namespace yourself. Immich Applications use CreateNamespace=false, so Argo will not create it:
kubectl create namespace immich-<instance>
  1. Seed Vault before you rely on SecretSynced. Put KV at secret/homelab/immich/immich-<instance> with the fields ESO expects. DATABASE_URL must use the in-cluster Postgres Service hostname immich-<instance>-postgresql. For a shared Google OAuth client, copy OAUTH_CLIENT_SECRET from another instance’s Vault path; generate fresh DB credentials and JWT for the new instance:
vault kv put secret/homelab/immich/immich-<instance> \
  DB_USERNAME='immich' \
  DB_PASSWORD='<db-password>' \
  DB_DATABASE='immich' \
  JWT_SECRET='<jwt-secret>' \
  DATABASE_URL='postgres://immich:<db-password>@immich-<instance>-postgresql:5432/immich' \
  OAUTH_CLIENT_SECRET='<google-oauth-client-secret>'
  1. Add a minimal overlay overlays/immich-<instance>/values.yaml with only instance-specific keys:
    • immich.persistence.library.existingClaim: immich-<instance>-library-pvc (concrete string, the chart cannot template this field)
    • immich.existingConfiguration: immich-<instance>-immich-config Shared homelabs/immich/values.yaml sets configurationKind: Secret, fleet image tag, Redis/DB env, the photos existingClaim (read-only mount), ML cache as emptyDir (no existingClaim), Recreate/startupProbe, and server/ML hardening. Non-secret OAuth fields (clientId, externalDomain, autoRegister, quota) live in the private ExternalSecret template.
  2. Extend AppProject homelabs-private/argocd/projects/homelab.yaml so both are allowed: the Immich OCI chart source (ghcr.io/immich-app/immich-charts) and destination namespace: immich-<instance>. Committing that file is not enough if AppProject is applied outside app-of-apps. Apply the live project so Argo accepts the new destination:
kubectl apply -f homelabs-private/argocd/projects/homelab.yaml

Without the live destination entry, sync fails with an error that the destination namespace does not match allowed destinations in project homelab. 8. Add the Argo CD Application under clusters/<cluster>/argocd/applications/immich-<instance>.yaml with valueFiles $homelabs/immich/values.yaml, then the private overlay. Secrets come only from Vault → ESO (no Helm secret valueFiles). Keep CreateNamespace=false. 9. Wire the public hostname: add a Cloudflare Tunnel Public Hostname for photos.<your-domain> (or your per-instance host) pointing at the Immich server ClusterIP Service, and authorize the three Google OAuth redirect URIs for that host (see OAuth login). 10. Commit homelabs-private (and homelabs only if you changed public templates). App-of-apps discovers the Application; sync in the Argo UI or argocd app sync immich-<instance>.

Bootstrap of a brand-new Application CR (once) may use kubectl apply -f .../immich-<instance>.yaml if app-of-apps has not picked it up yet; that is registration only, not an ongoing deploy path.

New-instance troubleshooting
#

Destination namespace not allowed. Git has the AppProject destination, but the live AppProject does not. Re-apply homelabs-private/argocd/projects/homelab.yaml (step 7), then retry sync.

Upgrade / change config
#

Immich version and chart pins live in git. Bump them there, then let Argo CD sync; never run helm upgrade or kubectl apply against a live Immich release. Large image jumps (for example v2.3.xv2.7.x) run schema migrations on Immich server boot; treat each instance as its own change window with a Postgres backup and a controlled sync.

Version pins
#

The Immich server image defaults from public shared values at homelabs/immich/values.yaml, under controllers.main.containers.main.image.tag (fleet baseline currently v3.1.0). That file is the only public Helm input Argo consumes via $homelabs/immich/values.yaml.

For a canary bump, override the same key in the private instance overlay (homelabs-private/clusters/<cluster>/overlays/immich-<instance>/values.yaml) so only that Application picks up the new tag. When every instance you care about is healthy on the new tag, promote the tag into the shared public values and remove the per-instance overlay overrides so the fleet pin lives in one place again.

Immich Applications source the Helm chart from OCI ghcr.io/immich-app/immich-charts chart immich at 0.13.1. AppProject sourceRepos must allow that OCI registry. Once no Application still points at the retired HTTP Immich charts repo, drop https://immich-app.github.io/immich-charts from the allow-list.

Immich v3 migration (OCI chart + VectorChord)
#

The fleet baseline is Immich v3.1.0 on OCI chart 0.13.1 with VectorChord Postgres. Upstream requires VectorChord before Immich v3 and documents the major cutover in Migrating to v3. Use the two-window path below for any leftover v2 instance, or as the template for the next major: database first, then chart and app, so a VectorChord failure does not collide with a Helm values rewrite. Commit the two phases separately; never push VectorChord and the v3 chart/image pin in one revision while auto-sync is on.

  1. Phase A: VectorChord only (pause Argo for a quiet window). Immich Admin Create Database Dump first. Temporarily remove spec.syncPolicy.automated on that Application so selfHeal cannot fight sequencing. In the private companion Postgres Deployment, digest-pin Immich’s published dual-extension image (ghcr.io/immich-app/postgres with VectorChord + pgvectors, matching the Immich release’s compose pin). Commit/sync postgres while the Immich app stays on the last v2 tag; wait until postgres is Ready and quiet, then restart the Immich server so reindex runs. Confirm logs show Reindexed clip_index and Reindexed face_index (and that pgvecto.rs is gone) before you bump the app. Soft-smoke the UI if you want extra confidence. Restore automated prune and selfHeal (git + live) when Phase A is green, or leave it paused only if you immediately continue into Phase B in the same sitting. Leaving auto-sync on through the postgres image cutover while Immich keeps reconnecting and retrying CREATE EXTENSION has caused Postgres system-catalog index corruption (XX002 on pg_depend_*); prefer the pause.
  2. Phase B: chart + app (full GitOps). On public homelabs, keep machine-learning.persistence.cache as type: emptyDir with sizeLimit: 4Gi and no existingClaim so chart 0.13.x / common 5.x render an ephemeral cache. Do not reintroduce PVC keys (size, accessMode, existingClaim) on that object; overlay-null cannot strip them. Allow ghcr.io/immich-app/immich-charts in AppProject sourceRepos. For a canary, retarget one Application to OCI chart 0.13.1 and set a private overlay image.tag. For fleet promote, set the shared public image.tag once, retarget remaining Applications to OCI, and drop per-instance overlay tags so every instance inherits $homelabs. With server strategy: Recreate and a long startupProbe, leave Argo automated on and skip scale-to-zero. If the Application CR is managed outside the instance’s own sync (for example app-of-apps), apply or sync that parent so the live chart source matches git. Verify Ready plus OAuth and library smoke.
  3. Hardware. Immich v3 machine-learning on amd64 needs guest CPUs at x86-64-v2 or higher (requirements). On a mixed hypervisor fleet, set every VM Immich may schedule on to a shared type such as x86-64-v2 (not host) so pods can move between nodes without NumPy X86_V2 CrashLoops.

The public companion shape homelabs/immich/postgres.yaml is the VectorChord digest-pin template; live instances use private manifests/immich-<instance>/postgresql.yaml promoted one Application at a time in Phase A.

Commit order:

  • Overlay-only canary: homelabs-private (main) first, then this docs site if pin text changed.
  • Fleet promote: homelabs (feature branch + PR to main with shared image.tag) → homelabs-private (OCI Applications + drop canary overlay tags) → website.

Upgrade hardening
#

Shared public values set the Immich server Deployment to strategy: Recreate so a version jump does not run two server images against one database at once. They also lengthen the server startupProbe (on the order of ~60 minutes) so large schema migrations can finish before Kubernetes kills the pod.

Prefer an overlay canary on one disposable or low-risk instance before promoting the fleet pin. Keep the Postgres catalog unless you deliberately choose a fresh rebuild; wiping the database leaves upload library and external photos files on NFS but drops catalog linkage until you re-import or restore a SQL dump.

Recommended path: backup and in-place migrate#

This is the default for instances that must keep users, albums, and library linkage. Do not delete the instance Postgres PVC. Repeat the window for one instance at a time (canary first, then each remaining Application).

Use Immich’s own database dump rather than a hand-rolled pg_dump workflow. Upstream documents Admin UI dumps and CLI restore under Backup and Restore. Dumps land under the instance library volume at UPLOAD_LOCATION/backups/ (mounted at /data on the server; on this cluster that is the instance NFS library share, for example <nfs-base-path>/library/backups/).

Set INSTANCE to the Argo Application / namespace name (for example immich-test), ensure the new pin is ready in git, then run:

INSTANCE=immich-<instance>
NS=$INSTANCE

# 0) Preflight
kubectl -n argocd get application "$INSTANCE"
kubectl -n "$NS" get deploy,pvc
kubectl -n "$NS" get deploy -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image

# 1) Phase A: pause Argo auto-sync + selfHeal for a quiet VectorChord window
kubectl -n argocd patch application "$INSTANCE" --type json \
  -p '[{"op":"remove","path":"/spec/syncPolicy/automated"}]'
kubectl -n argocd get application "$INSTANCE" -o jsonpath='{.spec.syncPolicy}{"\n"}'

# 2) Immich official DB backup (server must stay up)
#    Admin UI → Administration → Job Queues → Create job → Create Database Dump → Confirm
#    Wait for a new immich-db-backup-*.sql.gz under the instance library/backups/ share.
#    Optional: copy that file aside (NAS operator backup folder) before continuing.

# 3) Phase A (VectorChord only): commit/sync postgres only (leave server/ML up).
#    Wait until postgres Ready, then restart the server so VectorChord reindex runs.
# kubectl -n "$NS" rollout restart deploy/${INSTANCE}-server
# Confirm logs: Reindexed clip_index + Reindexed face_index; smoke UI/OAuth/search.
# Restore automated (git + live) when Phase A is green unless you continue straight to Phase B paused.

# 4) Phase B (v3 chart/app): second commit — OCI chart + overlay image.tag.
#    Full GitOps: leave automated on (or restore it first); Recreate rolls the server.
#    No scale-to-zero required. If the Application CR lags (app-of-apps), apply/sync it.
#    Then wait for migrations (startupProbe can take a long time).
kubectl -n "$NS" rollout status deploy/${INSTANCE}-server --timeout=60m
kubectl -n "$NS" logs deploy/${INSTANCE}-server -f --tail=200
# abort if "Migrations failed" — see Rollback below

# 5) Verify (and restore automated only if you still have it paused from Phase A)
kubectl -n "$NS" get deploy -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image
# UI smoke: login + library/assets still present
kubectl -n argocd patch application "$INSTANCE" --type merge \
  -p '{"spec":{"syncPolicy":{"automated":{"prune":true,"selfHeal":true},"syncOptions":["CreateNamespace=false"]}}}'
kubectl -n argocd annotate application "$INSTANCE" \
  argocd.argoproj.io/refresh=hard --overwrite

Keep each pre-upgrade dump until the whole fleet has soaked healthy on the new pin. Finish one instance Healthy with automated sync restored before starting the next. For restore, prefer Immich’s Admin Restore database backup flow (or the upstream CLI restore) from Backup and Restore rather than inventing a one-off pg_restore recipe.

Canary then fleet promote
#

Treat a multi-instance bump as a controlled rollout, not a simultaneous sync of every Application.

  1. Canary: Immich Create Database Dump, Phase A VectorChord with Argo paused then server restart/reindex, then Phase B OCI + overlay tag as a second GitOps commit (Recreate; no scale-to-zero). Verify, restore automated if still paused.
  2. Pause every other Application still on the old tag that consumes $homelabs main.
  3. Promote: set the same tag in public homelabs/immich/values.yaml, merge the homelabs PR to main, then remove canary overlay keys in homelabs-private and point any temporary $homelabs Application revision back at main.
  4. Roll remaining instances one at a time with the same Phase A quiet window → Phase B GitOps loop. Do not skip the per-instance backup because the canary succeeded; each database migrates independently.

Alternate path: version bump with empty database rebuild
#

Use this only when you accept losing the Immich catalog (users, albums, asset rows) or when an in-place migration is known-bad. Disable automated sync, scale workloads down, delete only the instance Postgres PVC so it recreates empty, wipe the Redis longhorn-ssd claim for that instance, sync the new image pin, and restore automated sync after Healthy. Keep photos and library NFS claims unless you intend a full storage wipe. See Fresh rebuild (database wiped).

Rollback
#

If the new image fails before migrations apply, remove or revert the overlay image.tag override (or restore the previous public pin), push/merge, and sync the affected Application(s). That alone is enough when the database never moved forward.

If a migration partially applied (server started, then failed mid-migration, or logs show schema changes already committed), reverting the image tag is not sufficient. Restore that instance’s Postgres from the pre-upgrade backup, then sync the Application on the previous image pin so the restored schema matches the code. Do not leave a restored database paired with a newer server image that expects further migrations.

Chart rollbacks follow the same GitOps pattern: set Application targetRevision back to the prior chart pin and sync. Prefer image-only rollbacks unless the chart change itself is the failure.

Operator checklist
#

A few habits keep upgrades safe. For VectorChord (Phase A), pause Argo automated sync so Immich is not CrashLooping CREATE EXTENSION through the postgres image cutover; restore automated when that window is green. For chart/image bumps that rely on server Recreate (Phase B), leave automated on and skip scale-to-zero unless you need an explicit downtime window. Never delete the Postgres PVC while you intend to keep the catalog. Take a pre-upgrade dump with Immich Create Database Dump (Admin Job Queues) before syncing a new image, see Backup and Restore. Do not start a new server image against a half-wiped or partially restored database, and never run two Immich server versions against one database; rely on Recreate. Keep the image pin on GitOps sync rather than a day-2 helm upgrade. Treat major chart jumps (new common-library schema, renamed values) as their own change window. Render with helm template against shared values plus a slim overlay before you sync.

Removal
#

  1. Delete or disable the Argo CD Application (prune companions + Helm release per sync policy).
  2. Delete remaining PVCs / namespace only if you intend to destroy data:
kubectl -n immich-<instance> delete pvc --all
kubectl delete namespace immich-<instance>

Cloudflare Tunnel
#

Point a public hostname at the Immich server ClusterIP Service. Prefer the shared tunnel documented under Cloudflare Tunnel; per-app kubectl apply of tunnel manifests is not the Immich deploy path.

Auth landing brand
#

Unauthenticated visitors can get a branded login page instead of Immich’s default chrome. Wire it only through the instance ExternalSecret Immich config template (manifests/immich-<instance>/externalsecret.yaml), not the Immich admin UI. Admin-UI Custom CSS alone drifts from git.

FieldPurpose
server.loginPageMessageStatic HTML welcome / marketing block (Immich product pitch, download + site links as on immich.app, plus a short note for this server). Wrap with mi-landing-msg. No scripts. Source file: landing-message.html.
theme.customCssCSS Immich serves at GET /custom.css. That endpoint is global, so keep selectors auth-scoped (for example under :has(.mi-landing-msg)). Bare body / button rules bleed into the logged-in UI. Source file: landing-theme.css.

Roll out on a non-production instance first (for example immich-test behind photos-test.<your-domain>). Check the logged-out landing, Google SSO, and a logged-in timeline for CSS bleed. Then promote the same CSS (and a production-tuned message) to the primary host. Other fleet instances stay on default Immich chrome until you opt them in the same way.

To verify, open the instance URL logged out, and confirm curl -sS https://photos-test.<your-domain>/custom.css (or your canary host) returns the landing rules. To roll back, remove or empty theme.customCss and server.loginPageMessage in that instance’s ExternalSecret, sync, and restart server pods.

Workload hardening
#

Shared homelabs/immich/values.yaml sets fleet defaults for Immich server and machine-learning: non-root UID/GID 1000, allowPrivilegeEscalation: false, drop ALL capabilities, and CPU/memory requests/limits. Private overlays keep only instance-specific keys (library existingClaim, existingConfiguration).

Companion NetworkPolicy shape lives in public homelabs/immich/networkpolicy.yaml (placeholders for instance name, namespace, LAN CIDR, and pod CIDR). Copy it into each private manifests/immich-<instance>/networkpolicy.yaml, fill site CIDRs from clusters/<cluster>/config/cluster.yaml / node inventory, and list it in that instance’s kustomization.yaml. Enforcement needs a CNI that implements NetworkPolicy.

Metrics
#

Shared values set immich.metrics.enabled: true. That is Immich’s native OpenTelemetry → Prometheus path plus a chart ServiceMonitor. Do not add a custom OTLP sidecar or NODE_OPTIONS auto-instrumentation; that combination crash-loops the server.

Each instance NetworkPolicy must allow the metrics ports from Prometheus in the monitoring namespace only. The user-facing HTTP port stays off that scrape rule. See Prometheus.

OAuth login (Google)
#

Immich authenticates with Google OIDC directly (https://accounts.google.com). Users click “Sign in with Google” and only see Google’s login page: no Dex hop and no GitHub option. Dex remains the IdP for Kubernetes and other admin apps.

OAuth lives in the ESO-templated Immich config Secret (manifests/immich-<instance>/externalsecret.yaml<release>-immich-config): non-secret fields (enabled, issuer, clientId, autoRegister, quota, password login) sit in the template; oauth.clientSecret is the only OAuth value pulled from Vault (OAUTH_CLIENT_SECRET on secret/homelab/immich/immich-<instance>).

Runtime clientId is whatever you put in that private ExternalSecret template. Optional google_client_id entries under clusters/<cluster>/config/cluster.yaml are site inventory metadata only; Immich does not read them at sync time. Placeholders there do not turn OAuth off.

Shared or per-instance Google client
#

You choose one Google Cloud OAuth Web client for all Immich hosts, or a separate client per instance. Do not reuse the Dex broker client (that callback is https://dex.<your-domain>/callback only).

A shared client is simpler to operate: put the same clientId in each ExternalSecret and the same secret value under each instance’s Vault OAUTH_CLIENT_SECRET. Authorize redirect URIs for every Immich hostname on that one client. The trade-off is a larger rotate/leak blast radius: one compromised secret affects every instance.

Per-instance clients shrink that blast radius: each hostname gets its own client ID and Vault secret. Redirect URIs stay the same shape; only the Google console and Vault seeds differ.

Authorized redirect URIs per hostname (replace host as needed):

  • https://photos.<your-domain>/auth/login
  • https://photos.<your-domain>/user-settings
  • https://photos.<your-domain>/api/oauth/mobile-redirect

Each public Immich hostname needs those three paths. With a shared client, list every hostname’s triple on the same Web client.

Auth model
#

LayerConfig
Cloudflare Tunnelphotos.<your-domain> (or per-instance hostname) → Immich server ClusterIP Service
Google CloudOAuth Web client(s) with Immich redirect URIs (shared or per host)
ImmichissuerUrlhttps://accounts.google.com; clientId in ExternalSecret; clientSecret from Vault
Access (free tier)autoRegister: true + defaultStorageQuota: 15 (GiB) on first Google sign-in
Access (invite-only)autoRegister: false; pre-create or link users in Immich admin

Free-tier self-registration
#

With autoRegister: true and a public tunnel hostname, any Google account that can open the UI can create an Immich user and consume the default quota (typically 15 GiB) until an admin acts. That blast radius is intentional for a free tier. Admins can tighten later: turn off auto-register, lower or zero the default quota, delete or disable unwanted users, or move to invite-only. Monitor new users after you enable Google SSO on a reachable host.

Run SSO-only: passwordLogin.enabled: false by default. Local email/password is not offered in the UI. The dedicated immich-demo instance enables password login only for the public Open Demo path. Keep a known local Immich admin credential offline for break-glass (re-enable below) if Google SSO fails.

Repo layout
#

RepoRole
homelabsShared Helm values (immich/values.yaml) + companion / ExternalSecret shapes
homelabs-privatePer-instance overlay, ExternalSecrets (incl. live clientId), manifests, Argo CD Applications
Vaultsecret/homelab/immich/immich-<instance>OAUTH_CLIENT_SECRET (see Vault / ESO)

Immich OAuth settings
#

SettingValue
Enabledtrue
Issuer URLhttps://accounts.google.com
Client IDGoogle OAuth client ID (private ExternalSecret template)
Client SecretGoogle OAuth client secret (Vault → ESO config Secret only)
Scopeopenid email profile
ID Token Signed Response AlgorithmRS256
External domainInstance public URL
Button TextSign in with Google
Auto Registertrue (free tier) or false (invite-only)
Default Storage QuotaPer ExternalSecret (free-tier hosts commonly 15 GiB; public shape example 5); 0 = unlimited
Auto Launchfalse
Mobile Redirect URI Overrideenabled
Mobile Redirect URIhttps://photos.<your-domain>/api/oauth/mobile-redirect
Password loginfalse (SSO-only); re-enable temporarily for break-glass

These fields live in the ESO-templated <release>-immich-config Secret (oauth.*), not in Helm values. After ExternalSecret changes: commit → Argo sync → wait for SecretSynced → restart Immich server if the config is not hot-reloaded.

Verify and harden
#

  1. Web: instance URL → Sign in with Google → only accounts.google.com.
  2. Mobile: OAuth via /api/oauth/mobile-redirect.
  3. Free tier (autoRegister: true): a new Google account should create an Immich user with the configured default quota.
  4. Invite-only (autoRegister: false): create or link the user before first SSO login.
  5. IdP cutover: if linked under a previous issuer (e.g. Dex), temporarily re-enable password login (break-glass) → Account → OAuth → Unlink → Link Google once → return to SSO-only.
  6. Confirm password login is off: local email/password should not work; Google SSO should.

Password login (SSO-only)
#

Shipped config sets password login off in the ESO Immich config template (manifests/immich-<instance>/externalsecret.yaml):

passwordLogin:
  enabled: false

After ExternalSecret changes: commit → Argo sync → wait for SecretSynced → restart Immich server pods (config is not hot-reloaded). Keep a local Immich admin email/password offline for break-glass.

Break-glass (SSO broken)
#

  1. Set passwordLogin.enabled: true in the ExternalSecret Immich config template → commit → Argo sync → wait for SecretSynced → restart Immich server pods.
  2. Sign in with local admin email + password.
  3. Fix OAuth → set passwordLogin.enabled: false again → sync → restart Immich server pods.

Sessions and re-login (Google SSO)
#

After Google OAuth succeeds, Immich creates its own long-lived session. It does not expire when the Google ID token expires.

ControlWhat it does
User LogoutEnds that Immich session
Admin → revoke / delete sessionsSame for a user or device
Google OAuth prompt (Immich ≥ 3.0)Forces Google re-auth UI on each Sign in with Google
Future Immich session TTLTrack upstream; not configured as a first-class GitOps knob today

kubectl / Dex session length is separate (Dex expiry).