Immich is a self-hosted photo and video backup. You run one instance as an Argo CD Application: the upstream OCI Helm chart, a slim private overlay, and companion manifests for PVCs, Postgres, Redis, and NetworkPolicy. Change git and let Argo sync. Skip day-2 kubectl apply, helm upgrade, and old deploy scripts.
GitOps model#
| Piece | Where | Argo role |
|---|---|---|
| Application CR | homelabs-private/.../argocd/applications/immich-<instance>.yaml | Multi-source app |
| Shared Helm values | homelabs/immich/values.yaml | The only public file Argo Helm consumes (valueFiles) |
| Instance overlay | .../overlays/immich-<instance>/values.yaml | Library existingClaim + existingConfiguration only |
| Secrets / Immich config | .../manifests/immich-<instance>/externalsecret.yaml | ESO → db / app / immich-config Secrets (Vault) |
| PVC / Postgres / Redis | .../manifests/immich-<instance>/ (kustomization.yaml) | Sync-wave -1 companions |
| Public companion shapes | homelabs/immich/ (pvc.yaml, redis.yaml, postgres.yaml, networkpolicy.yaml, manifests/externalsecret.yaml) | Copy into private manifests; Argo applies the private tree |
| Landing CSS / message | homelabs-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. Shared values set valkey.enabled: false and REDIS_HOSTNAME: <release>-redis so the app talks to that 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 is Helm values plus companion shapes. Wire external-import photos through Helm server.persistence.photos in values.yaml (read-only /mnt/photos).
Storage volumes#
The instance mounts four companion PersistentVolumeClaims (shapes in homelabs/immich/pvc.yaml, copied into private manifests) plus an ephemeral ML cache (emptyDir, no PVC).
| Volume | Purpose | Server / app may write? | Typical attach |
|---|---|---|---|
library | Immich upload library (mounted at /data) | Yes | Shared NFS (nfs-csi, RWX) |
photos | External-import tree (mounted at /mnt/photos) | No, read-only | Shared NFS (nfs-csi, ReadOnlyMany) |
ml-cache | Machine-learning model cache | Yes | emptyDir, 4Gi sizeLimit (node disk, not Memory) |
redis | Redis AOF persistence | Yes | longhorn-ssd RWO, 2Gi (*-redis-pvc-lh) |
postgresql | Postgres data | Yes | longhorn-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 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 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>/photosThose are the only Immich NFS trees. Redis, Postgres, and the ML cache live on cluster disks, so skip redis/, ml-cache/, and 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 Proxmox.
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.
- Update inventory (
cluster.yaml) and privatepvc.yamlshares to<nfs-base-path>/{library,photos}. Create those two directories on the NAS and confirm NFS ACLs cover every node. - Scale Immich workloads down (server, machine-learning, redis; leave Postgres alone unless you are also wiping the database).
- Delete the NFS PVCs and their Bound static PVs for that instance (
library,photosonly). Do not delete the Postgres or Redis Longhorn PVCs unless you intend a catalog or queue wipe. - Commit the private manifest changes, then Argo sync so companions recreate PVs/PVCs at the new path.
- 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.
Restore a native Immich SQL dump#
The companion Postgres image runs init.sql on an empty data directory (CREATE EXTENSION for cube, earthdistance, and vectors). Immich’s own dump often starts with DROP SCHEMA vectors. That fails while the extension still owns the schema (psql exit 3). Drop the extensions, then retry the built-in restore from the UI. Do not run this against a catalog you still need:
DROP EXTENSION IF EXISTS earthdistance CASCADE;
DROP EXTENSION IF EXISTS cube CASCADE;
DROP EXTENSION IF EXISTS vectors CASCADE;
DROP SCHEMA IF EXISTS vectors CASCADE;New Longhorn disks can have a lost+found directory at the volume root, which collides with Postgres initdb. Private companion Deployments set PGDATA to a subdirectory (.../pgdata) and strategy: Recreate for the single RWO replica. Add those when you copy the public postgres.yaml pin. Do not set PGDATA on a volume whose existing cluster already lives at the mount root.
Shared Helm values set runAsUser / runAsGroup 1000 on server and machine-learning and omit fsGroup. A Synology NFS export root is often not gid 1000. With fsGroup, kubelet recursively chowns the library on every new pod, even with fsGroupChangePolicy: OnRootMismatch, and the server sits in ContainerCreating with only a Scheduled event. Do not add fsGroup back in a private overlay.
See Argo CD for why dropping a tracked namespace.yaml with prune: true deletes the live Namespace (and its PVCs). CreateNamespace=true does not prevent that.
Deploy a new instance#
Stand the instance up from public companion shapes: copy them into the private tree, fill placeholders, then wire Vault, AppProject, namespace, and public hostname before the first sync.
- Choose
<nfs-server>and<nfs-base-path>; record them underimmich.instances.<name>.nfsinclusters/<cluster>/config/cluster.yaml. On the NAS, create onlylibrary/andphotos/under that base. Postgres uses a dynamiclonghorn-ssdclaim (<release>-postgresql-pvc-lh, 10Gi) and Redis uses<release>-redis-pvc-lh(2Gi, Recreate). Machine-learning cache isemptyDir; do not create NFSpostgres/,redis/, orml-cache/directories. - Confirm Synology NFS permissions for the shared folder (see above) include every node that may schedule pods.
- Copy companion shapes from
homelabs/immich/intohomelabs-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 nopatch.yamlor secret template in the public tree. - Create the destination namespace yourself. The Immich Application uses
CreateNamespace=false, so Argo will not create it:
kubectl create namespace immich-<instance>- Seed Vault before you rely on
SecretSynced. Put KV atsecret/homelab/immich/immich-<instance>with the fields ESO expects.DATABASE_URLmust use the in-cluster Postgres Service hostnameimmich-<instance>-postgresql. Generate DB credentials and JWT, and put the Google OAuth client secret:
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>'- Add a minimal overlay
overlays/immich-<instance>/values.yamlwith 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-configSharedhomelabs/immich/values.yamlsetsconfigurationKind: Secret, the image tag, Redis/DB env, the photosexistingClaim(read-only mount), ML cache asemptyDir(noexistingClaim), Recreate/startupProbe, and server/ML hardening. Non-secret OAuth fields (clientId,externalDomain,autoRegister, quota) live in the private ExternalSecret template.
- Extend AppProject
homelabs-private/argocd/projects/homelab.yamlso both are allowed: the Immich OCI chart source (ghcr.io/immich-app/immich-charts) and destinationnamespace: 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.yamlWithout 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> pointing at the Immich server 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.x → v2.7.x) run schema migrations on Immich server boot; treat the upgrade 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 (currently v3.1.0). That file is the only public Helm input Argo consumes via $homelabs/immich/values.yaml. Override the same key in the private overlay (homelabs-private/clusters/<cluster>/overlays/immich-<instance>/values.yaml) only when you need a one-off pin; otherwise keep the tag in shared values.
The Immich Application sources 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 the Application no longer 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 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 a 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.
- Phase A: VectorChord only (pause Argo for a quiet window). Immich Admin Create Database Dump first. Temporarily remove
spec.syncPolicy.automatedon the Application so selfHeal cannot fight sequencing. In the private companion Postgres Deployment, digest-pin Immich’s published dual-extension image (ghcr.io/immich-app/postgreswith 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 showReindexed clip_indexandReindexed face_index(and that pgvecto.rs is gone) before you bump the app. Optionally smoke the UI. 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 retryingCREATE EXTENSIONhas caused Postgres system-catalog index corruption (XX002onpg_depend_*); prefer the pause. - Phase B: chart + app (full GitOps). On public
homelabs, keepmachine-learning.persistence.cacheastype: emptyDirwithsizeLimit: 4Giand noexistingClaimso chart0.13.x/ common5.xrender an ephemeral cache. Do not reintroduce PVC keys (size,accessMode,existingClaim) on that object; overlay-null cannot strip them. Allowghcr.io/immich-app/immich-chartsin AppProjectsourceRepos. Retarget the Application to OCI chart0.13.1and setimage.tagin shared public values (or the private overlay for a one-off pin). With serverstrategy: Recreateand 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. - Hardware. Immich v3 machine-learning on
amd64needs guest CPUs at x86-64-v2 or higher (requirements). On mixed hypervisor nodes, set every VM Immich may schedule on to a shared type such asx86-64-v2(nothost) so pods can move between nodes without NumPyX86_V2CrashLoops.
The public companion shape homelabs/immich/postgres.yaml is the VectorChord digest-pin template; the live instance uses private manifests/immich-<instance>/postgresql.yaml in Phase A.
Commit order: public pin changes go homelabs (feature branch + PR to main) → homelabs-private (Application / overlay) → website. Overlay-only or companion-only changes go homelabs-private (main) first, then this docs site if pin text changed.
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.
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 when you must keep users, albums, and library linkage. Do not delete the Postgres PVC.
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 (immich-<instance>), 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 --overwriteKeep the pre-upgrade dump until the instance is Healthy on the new pin with automated sync restored. 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.
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 Application. 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#
- Pause Argo automated sync for VectorChord (Phase A) so Immich is not CrashLooping
CREATE EXTENSIONthrough the postgres image cutover; restore automated when that window is green. - For chart or image bumps that rely on server
Recreate(Phase B), leave automated on and skip scale-to-zero unless you need an explicit downtime window. - Keep 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.
- Run one Immich server version against the 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 templateagainst shared values plus a slim overlay before you sync.
Removal#
- Delete or disable the Argo CD Application (prune companions + Helm release per sync policy).
- 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 Service. Shared values set that Service to LoadBalancer; the tunnel origin is the Service DNS. Use the shared tunnel documented under Cloudflare Tunnel. Add the hostname in Zero Trust rather than attaching per-app tunnel manifests to the Immich Application.
Auth landing brand#
Unauthenticated visitors can get a branded login page. Wire it through the instance ExternalSecret Immich config template (manifests/immich-<instance>/externalsecret.yaml). Admin-UI Custom CSS alone drifts from git.
| Field | Purpose |
|---|---|
server.loginPageMessage | Static 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.customCss | CSS 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 the CSS and message, then check the logged-out landing, Google SSO, and a logged-in timeline for CSS bleed.
To verify, open the instance URL logged out, and confirm curl -sS https://photos.<your-domain>/custom.css returns the landing rules. To roll back, remove or empty theme.customCss and server.loginPageMessage in the ExternalSecret, sync, and restart server pods.
Workload hardening#
Shared homelabs/immich/values.yaml sets 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).
The companion NetworkPolicy shape lives in public homelabs/immich/networkpolicy.yaml (placeholders for instance name, namespace, LAN CIDR, and pod CIDR). Copy it into manifests/immich-<instance>/networkpolicy.yaml, fill site CIDRs from clusters/<cluster>/config/cluster.yaml / node inventory, and list it in 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.
The 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 at https://accounts.google.com. Users click Sign in with Google and finish on Google’s page. Dex stays 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.
Google OAuth client#
Use one Google Cloud OAuth Web client for the Immich hostname. Do not reuse the Dex broker client (that callback is https://dex.<your-domain>/callback only). Put clientId in the ExternalSecret and OAUTH_CLIENT_SECRET in Vault. Authorize these redirect URIs:
https://photos.<your-domain>/auth/loginhttps://photos.<your-domain>/user-settingshttps://photos.<your-domain>/api/oauth/mobile-redirect
Those three paths are required on the Web client.
Auth model#
| Layer | Config |
|---|---|
| Cloudflare Tunnel | photos.<your-domain> → Immich server Service |
| Google Cloud | OAuth Web client with Immich redirect URIs |
| Immich | issuerUrl → https://accounts.google.com; clientId in ExternalSecret; clientSecret from Vault |
| Access | autoRegister: false; pre-create or link users in Immich admin |
Invite-only access#
The instance is invite-only: oauth.autoRegister: false. A new Google account does not get an Immich user. Existing users still sign in with Google. Operators create or link accounts in Immich admin. On a branded landing, a failed new-user OAuth keeps Immich’s OAuth authentication failed alert and adds a short capacity notice in the same red style. The visitor-facing copy does not state a numeric cap. defaultStorageQuota: 15 stays in GitOps for any user an admin creates, or that you later re-enable auto-register for. Existing catalog quotas stay until an admin edits them.
Run SSO-only: passwordLogin.enabled: false. The UI does not offer local email/password. Keep a known local Immich admin credential offline for break-glass (re-enable below) if Google SSO fails.
Repo layout#
| Repo | Role |
|---|---|
homelabs | Shared Helm values (immich/values.yaml) + companion / ExternalSecret shapes |
homelabs-private | Private overlay, ExternalSecrets (incl. live clientId), manifests, Argo CD Application |
| Vault | secret/homelab/immich/immich-<instance> → OAUTH_CLIENT_SECRET (see Vault / ESO) |
Immich OAuth settings#
| Setting | Value |
|---|---|
| Enabled | true |
| Issuer URL | https://accounts.google.com |
| Client ID | Google OAuth client ID (private ExternalSecret template) |
| Client Secret | Google OAuth client secret (Vault → ESO config Secret only) |
| Scope | openid email profile |
| ID Token Signed Response Algorithm | RS256 |
| External domain | Instance public URL |
| Button Text | Sign in with Google |
| Auto Register | false (invite-only) |
| Default Storage Quota | 15 GiB (public shape example matches); 0 = unlimited |
| Auto Launch | false |
| Mobile Redirect URI Override | enabled |
| Mobile Redirect URI | https://photos.<your-domain>/api/oauth/mobile-redirect |
| Password login | false (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#
- Web: instance URL → Sign in with Google → only
accounts.google.com. - Mobile: OAuth via
/api/oauth/mobile-redirect. - Invite-only: a new Google account must not create a user. On a branded landing, that rejected sign-in should show Immich’s OAuth error plus the red capacity notice. An existing user should still sign in.
- 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.
- 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: falseThe public ExternalSecret shape still ships passwordLogin.enabled: true. Flip it to false in the private template for SSO-only. 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)#
- Set
passwordLogin.enabled: truein the ExternalSecret Immich config template → commit → Argo sync → wait forSecretSynced→ restart Immich server pods. - Sign in with local admin email + password.
- Fix OAuth → set
passwordLogin.enabled: falseagain → 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.
| Control | What it does |
|---|---|
| User Logout | Ends that Immich session |
| Admin → revoke / delete sessions | Same 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 TTL | Track upstream; not configured as a first-class GitOps knob today |
kubectl / Dex session length is separate (Dex expiry).