Skip to main content

Immich

Immich is a self-hosted photo and video library with a native mobile app that auto-uploads from the phone's camera roll, face / object recognition that runs locally, and a timeline UI that feels lifted straight from Google Photos. In this homelab it's the canonical photo store — backed by CNPG Postgres, Valkey for the queue and cache, Longhorn-encrypted PVCs for the thumbnails and ML models, and the photo originals themselves on an NFS mount from TrueNAS.

Why Immich

  • Mobile-first auto-upload. The Android/iOS app handles background sync without the user having to think about it; this is what makes a self-hosted replacement actually viable. Most of the alternatives skipped this.
  • Local ML. Face clustering, object tagging, and CLIP-based semantic search all run in-cluster on the immich-machine-learning Deployment. No third-party API calls, no photos leaving the house.
  • Shared albums + external editors out of the box. Day-to-day photo workflow with another person doesn't require workarounds.
  • Active development. The project moves fast — features that were missing six months ago (live photos, RAW handling, search) ship steadily. The cost is occasional schema migrations on upgrade; it's the right trade.

Alternatives considered

OptionHostedSelf-hostedWhy not
Google PhotosThe whole point of the homelab is to leave this
Apple Photos / iCloudLocked to Apple devices; per-seat pricing for storage
PhotoPrismStrong indexer, weaker mobile auto-upload story
LibrePhotosTracked, but Immich's UX + release cadence pulled ahead
Ente (self-hosted)End-to-end encrypted, which is great — but rules out server-side ML/search

Installation

The Immich install is the most substantial app in the cluster — two workloads, a Postgres cluster, a Valkey StatefulSet, three PVCs, and an NFS mount — composed from the platform primitives:

  • Two Deployments in the immich namespace. immich-server handles the API + web UI; immich-machine-learning runs the model inference. Both digest-pinned, both bumped by Renovate at the same version.
  • Postgres via CNPG. A dedicated Cluster with the pgvecto.rs extension that Immich requires for CLIP embeddings; data PVCs on the longhorn-encrypted storage class.
  • Cache + queue via Valkey. A small StatefulSet rather than a Deployment because it owns durable state for queued ML jobs.
  • Storage split across three roles.
    • immich-data PVC — thumbnails, transcoded previews, profile images. longhorn-encrypted, annotated for k8up backup.
    • immich-machine-learning-data PVC — the downloaded model weights. Reproducible, so not backed up.
    • NFS PV immich-truenas-nfs-images — the original photos and videos, on TrueNAS. Read-write from the cluster, snapshotted at the storage layer.
  • Routing via HTTPRoute on the public Envoy Gateway listener; TLS via cert-manager at the gateway.
  • Security context. App containers run as runAsUser: 999, non-root, all capabilities dropped. Valkey runs an init container as root to fix permissions, then the main container drops down.

Administration

  • Initial admin setup. First browser hit creates the admin account; subsequent users are invited from the admin panel or self-register if signup is enabled.
  • OIDC sign-in. Immich supports OIDC against Keycloak; the config lives in the admin UI rather than env vars. Map the email claim to the Immich email field.
  • External libraries. Admin → External Libraries points Immich at the NFS-mounted photo paths; scans run periodically. This is how the existing photo archive shows up alongside fresh mobile uploads.
  • Reindexing after a model bump. When the machine-learning image version changes the embedding format, the search index needs a rerun: Admin → Jobs → Smart Search → Run (all). CLIP and face recognition each have their own job.
  • Upgrades. Renovate auto-merges patch / digest bumps per the Renovate policy; minor and major bumps wait for a human read because they sometimes carry irreversible schema migrations. Always read the release notes for migrations before merging.
  • Backups. Two-track: the CNPG cluster uses k8up.io/backupcommand: pg_dump (so the restic snapshot captures a consistent SQL dump rather than a hot data directory), and the immich-data PVC is backed up via the k8up.io/backup annotation. Originals on NFS are TrueNAS's responsibility — see Topics → Backups end-to-end for the full chain.

Usage

  • Mobile app (App Store / Play Store, also on F-Droid) — point at https://immich.web.kueber.eu, log in, enable background backup. That's the whole onboarding.
  • Web UI for browsing the timeline, managing albums, running search ("photos of X at Y"), and admin tasks.
  • Shared albums. Add another Immich account (or a public link with optional password / expiry) to share a slice of the library.
  • External editors. The mobile app hands off to whatever the OS has registered for image editing; edits get re-uploaded.
  • CLI / API. immich-cli for bulk uploads from a workstation (useful for ingesting an existing photo archive); the REST API is documented and stable enough for ad-hoc automation.

Cluster Deployment

Immich — Talos cluster

Cluster-specific notes only. General product info, "why we use it", and alternatives live in docusaurus/docs/apps/immich.mdx.

Deviations from defaults

Defaults live in docusaurus/docs/apps/immich.mdx — document anything this cluster does differently here, with a one-line reason.

Kubernetes Metadata
  • Image: ghcr.io/immich-app/immich-machine-learning:v2.7.5@sha256:a2501141440f10516d329fdfba2c68082e19eb9ba6016c061ac80d23beadf7f3
  • Image: ghcr.io/immich-app/immich-server:v2.7.5@sha256:c15bff75068effb03f4355997d03dc7e0fc58720c2b54ad6f7f10d1bc57efaa5
  • Image: valkey/valkey:9.1.0-alpine@sha256:a35428eba9043cc0b79dbe54100f0c92784f2de00ad09b01182bfb1c5c83d1bd
Rendered manifests (kustomize build)
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app: immich-machine-learning
name: immich-machine-learning
namespace: immich
spec:
replicas: 1
selector:
matchLabels:
app: immich-machine-learning
ingress: public
strategy:
type: Recreate
template:
metadata:
labels:
app: immich-machine-learning
ingress: public
spec:
containers:
- env:
- name: DB_HOSTNAME
valueFrom:
secretKeyRef:
key: host
name: cnpg-app
- name: DB_DATABASE_NAME
valueFrom:
secretKeyRef:
key: dbname
name: cnpg-app
- name: DB_USERNAME
valueFrom:
secretKeyRef:
key: user
name: cnpg-app
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
key: password
name: cnpg-app
- name: DB_PORT
valueFrom:
secretKeyRef:
key: port
name: cnpg-app
envFrom:
- secretRef:
name: immich
image: >-
ghcr.io/immich-app/immich-machine-learning:v2.7.5@sha256:a2501141440f10516d329fdfba2c68082e19eb9ba6016c061ac80d23beadf7f3
imagePullPolicy: Always
name: immich-machine-learning
ports:
- containerPort: 3003
name: web
resources:
limits:
memory: 4004Mi
requests:
cpu: 10m
memory: 804Mi
volumeMounts:
- mountPath: /cache
name: immich-machine-learning-data
securityContext:
seccompProfile:
type: RuntimeDefault
volumes:
- name: immich-machine-learning-data
persistentVolumeClaim:
claimName: immich-machine-learning-data
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
kustomize.toolkit.fluxcd.io/force: enabled
labels:
app: immich-server
name: immich-server
namespace: immich
spec:
replicas: 1
selector:
matchLabels:
app: immich-server
ingress: public
strategy:
rollingUpdate: null
type: Recreate
template:
metadata:
labels:
app: immich-server
ingress: public
spec:
containers:
- env:
- name: DB_HOSTNAME
valueFrom:
secretKeyRef:
key: host
name: cnpg-app
- name: DB_DATABASE_NAME
valueFrom:
secretKeyRef:
key: dbname
name: cnpg-app
- name: DB_USERNAME
valueFrom:
secretKeyRef:
key: user
name: cnpg-app
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
key: password
name: cnpg-app
- name: DB_PORT
valueFrom:
secretKeyRef:
key: port
name: cnpg-app
envFrom:
- secretRef:
name: immich
image: >-
ghcr.io/immich-app/immich-server:v2.7.5@sha256:c15bff75068effb03f4355997d03dc7e0fc58720c2b54ad6f7f10d1bc57efaa5
imagePullPolicy: Always
name: immich-server
ports:
- containerPort: 2283
name: web
readinessProbe:
httpGet:
path: /
port: 2283
initialDelaySeconds: 5
periodSeconds: 5
successThreshold: 1
resources:
limits: {}
requests:
cpu: 10m
memory: 984Mi
volumeMounts:
- mountPath: /photos
name: immich-files
subPath: photos
- mountPath: /usr/src/app/upload
name: immich-data
- mountPath: /usr/src/app/upload/upload
name: immich-files
subPath: immich
securityContext:
seccompProfile:
type: RuntimeDefault
volumes:
- name: immich-files
persistentVolumeClaim:
claimName: immich-truenas-nfs-images
- name: immich-data
persistentVolumeClaim:
claimName: immich-data
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: valkey
namespace: immich
spec:
replicas: 1
selector:
matchLabels:
app: valkey
serviceName: valkey
template:
metadata:
labels:
app: valkey
spec:
containers:
- args:
- valkey-server
image: valkey/valkey:9.1.0-alpine@sha256:a35428eba9043cc0b79dbe54100f0c92784f2de00ad09b01182bfb1c5c83d1bd
livenessProbe:
initialDelaySeconds: 10
periodSeconds: 10
tcpSocket:
port: 6379
name: valkey
ports:
- containerPort: 6379
name: client
readinessProbe:
initialDelaySeconds: 3
periodSeconds: 5
tcpSocket:
port: 6379
resources:
limits:
memory: 512Mi
requests:
cpu: 50m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
privileged: false
volumeMounts:
- mountPath: /conf
name: conf
- mountPath: /data
name: data
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
runAsGroup: 1000
runAsNonRoot: true
runAsUser: 999
seccompProfile:
type: RuntimeDefault
volumes:
- emptyDir: {}
name: conf
- emptyDir: {}
name: data