From 8e6328424097483efbedc9bedbc0a2cce009d891 Mon Sep 17 00:00:00 2001 From: mr-forust Date: Mon, 28 Sep 2026 16:42:05 +0200 Subject: [PATCH] feat(immich): add self-hosted photo backup with dedicated postgres Server x2, machine learning, valkey and VectorChord postgres on local storage, Traefik routes for external and internal access. --- immich/.env.example | 24 ++++++ immich/compose.yaml | 63 ++++++++++++++ immich/k8s/active | 0 immich/k8s/certificates.yaml | 28 +++++++ immich/k8s/config.yaml | 24 ++++++ immich/k8s/immich.yaml | 95 +++++++++++++++++++++ immich/k8s/ingress.yaml | 36 ++++++++ immich/k8s/machine-learning.yaml | 92 +++++++++++++++++++++ immich/k8s/namespace.yaml | 5 ++ immich/k8s/postgres.yaml | 138 +++++++++++++++++++++++++++++++ immich/k8s/secrets.yaml.example | 13 +++ immich/k8s/valkey.yaml | 84 +++++++++++++++++++ 12 files changed, 602 insertions(+) create mode 100644 immich/.env.example create mode 100644 immich/compose.yaml create mode 100644 immich/k8s/active create mode 100644 immich/k8s/certificates.yaml create mode 100644 immich/k8s/config.yaml create mode 100644 immich/k8s/immich.yaml create mode 100644 immich/k8s/ingress.yaml create mode 100644 immich/k8s/machine-learning.yaml create mode 100644 immich/k8s/namespace.yaml create mode 100644 immich/k8s/postgres.yaml create mode 100644 immich/k8s/secrets.yaml.example create mode 100644 immich/k8s/valkey.yaml diff --git a/immich/.env.example b/immich/.env.example new file mode 100644 index 0000000..8887094 --- /dev/null +++ b/immich/.env.example @@ -0,0 +1,24 @@ +# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables + +# The location where your uploaded files are stored. The k8s manifests bind +# mount /mnt/immich/library, which is the sdc9 partition - the same place, so +# the two deployment paths look at one library. +UPLOAD_LOCATION=/mnt/immich/library + +# The location where your database files are stored. Network shares are not supported for the database +DB_DATA_LOCATION=./postgres + +# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List +# TZ=Etc/UTC + +# The Immich version to use. You can pin this to a specific version like "v2.1.0" +IMMICH_VERSION=v3 + +# Connection secret for postgres. You should change it to a random password +# Please use only the characters `A-Za-z0-9`, without special characters or spaces +DB_PASSWORD=postgres + +# The values below this line do not need to be changed +################################################################################### +DB_USERNAME=postgres +DB_DATABASE_NAME=immich diff --git a/immich/compose.yaml b/immich/compose.yaml new file mode 100644 index 0000000..9de4a71 --- /dev/null +++ b/immich/compose.yaml @@ -0,0 +1,63 @@ +name: immich + +services: + immich-server: + container_name: immich_server + image: ghcr.io/immich-app/immich-server:v3 + volumes: + - ${UPLOAD_LOCATION}:/data + - /etc/localtime:/etc/localtime:ro + env_file: + - .env + ports: + - '2283:2283' + depends_on: + - redis + - database + restart: always + healthcheck: + disable: false + + immich-machine-learning: + container_name: immich_machine_learning + # For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag. + # Example tag: ${IMMICH_VERSION:-release}-cuda + image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release} + # extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration + # file: hwaccel.ml.yml + # service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable + volumes: + - model-cache:/cache + env_file: + - .env + restart: always + healthcheck: + disable: false + + redis: + container_name: immich_redis + image: docker.io/valkey/valkey:9@sha256:70739f85ad2ee01a726a965584a0f94895f01b0c60b3cc8b0aeef11eaa6888cf + healthcheck: + test: redis-cli ping | grep -q PONG || exit 1 + restart: always + + database: + container_name: immich_postgres + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_DB: ${DB_DATABASE_NAME} + POSTGRES_INITDB_ARGS: '--data-checksums' + # Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs + # DB_STORAGE_TYPE: 'HDD' + volumes: + # Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file + - ${DB_DATA_LOCATION}:/var/lib/postgresql/data + shm_size: 128mb + restart: always + healthcheck: + disable: false + +volumes: + model-cache: diff --git a/immich/k8s/active b/immich/k8s/active new file mode 100644 index 0000000..e69de29 diff --git a/immich/k8s/certificates.yaml b/immich/k8s/certificates.yaml new file mode 100644 index 0000000..d5fe0e1 --- /dev/null +++ b/immich/k8s/certificates.yaml @@ -0,0 +1,28 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: immich-prod-tls + namespace: immich +spec: + secretName: immich-prod-tls + dnsNames: + - immich.forust.xyz + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: internal-wildcard-tls + namespace: immich +spec: + secretName: internal-wildcard-tls + dnsNames: + - "*.workstation.internal" + - "*.gigaforust.internal" + - workstation.internal + - gigaforust.internal + issuerRef: + name: internal-ca + kind: ClusterIssuer diff --git a/immich/k8s/config.yaml b/immich/k8s/config.yaml new file mode 100644 index 0000000..b8a2fce --- /dev/null +++ b/immich/k8s/config.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: immich-config + namespace: immich +data: + TZ: "Europe/Bratislava" + + # The database in this namespace, not the shared one in the database + # namespace: v3 needs VectorChord, and only the dedicated image carries it. + DB_HOSTNAME: "immich-postgres" + DB_PORT: "5432" + DB_USERNAME: "immich" + DB_DATABASE_NAME: "immich" + DB_SSL_MODE: "disable" + DB_VECTOR_EXTENSION: "vectorchord" + + REDIS_HOSTNAME: "immich-valkey" + REDIS_PORT: "6379" + + # Traefik is the only client of the server, and it is a pod: the address immich + # sees is inside the node's pod CIDR. Without this the server does not trust + # X-Forwarded-For and every request looks like it came from Traefik itself. + IMMICH_TRUSTED_PROXIES: "10.244.0.0/24" diff --git a/immich/k8s/immich.yaml b/immich/k8s/immich.yaml new file mode 100644 index 0000000..d22397d --- /dev/null +++ b/immich/k8s/immich.yaml @@ -0,0 +1,95 @@ +apiVersion: v1 +kind: Service +metadata: + name: immich-service + namespace: immich +spec: + selector: + app: immich + ports: + - name: http + port: 2283 + targetPort: 2283 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-deployment + namespace: immich + labels: + app: immich +spec: + replicas: 2 + selector: + matchLabels: + app: immich + template: + metadata: + labels: + app: immich + spec: + containers: + - name: immich + image: ghcr.io/immich-app/immich-server:v3 + envFrom: + - configMapRef: + name: immich-config + - secretRef: + name: immich-secrets + ports: + - name: http + containerPort: 2283 + volumeMounts: + - name: immich-data + mountPath: /data + # The first boot runs migrations and warms the transcoder, which can + # take minutes, so liveness has to wait on the startup probe. + startupProbe: + httpGet: + path: /api/server/ping + port: http + failureThreshold: 60 + periodSeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /api/server/ping + port: http + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /api/server/ping + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + # Only the request is scheduled against, and the node is already + # oversubscribed (5.58 of 6 cores requested) while actually running + # at about 1.5. So the request states what this sits at while idle - + # tens of millicores - and the limit leaves room for the burst that + # matters: thumbnails, transcodes and metadata extraction. + # + # The limit used to be 2Gi, but the server OOMKilled on boot while + # chewing through a backlog of unprocessed assets (API +Workers in + # one container spike well past idle). + resources: + requests: + cpu: "100m" + memory: "512Mi" + limits: + cpu: "1500m" + memory: "4Gi" + volumes: + - name: immich-data + # The library lives on the node's own disk, not in a PVC. A PVC here + # meant declaring a size up front for data that does not exist yet, + # on a provisioner that cannot grow it, and the only copy of the + # photos was one `kubectl delete namespace` away. + # + # Directory, not DirectoryOrCreate, on purpose: if sdc9 is not + # mounted, this must fail loudly instead of quietly writing the + # library onto the root filesystem. + hostPath: + path: /mnt/immich/library + type: Directory diff --git a/immich/k8s/ingress.yaml b/immich/k8s/ingress.yaml new file mode 100644 index 0000000..322f959 --- /dev/null +++ b/immich/k8s/ingress.yaml @@ -0,0 +1,36 @@ +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: immich-prod + namespace: immich +spec: + entryPoints: + - websecure + routes: + - match: Host(`immich.forust.xyz`) + kind: Rule + middlewares: + - name: crowdsec-bouncer + namespace: crowdsec + services: + - name: immich-service + port: 2283 + tls: + secretName: immich-prod-tls +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: immich-local + namespace: immich +spec: + entryPoints: + - websecure + routes: + - match: Host(`immich.workstation.internal`) || Host(`immich.gigaforust.internal`) + kind: Rule + services: + - name: immich-service + port: 2283 + tls: + secretName: internal-wildcard-tls diff --git a/immich/k8s/machine-learning.yaml b/immich/k8s/machine-learning.yaml new file mode 100644 index 0000000..29eccde --- /dev/null +++ b/immich/k8s/machine-learning.yaml @@ -0,0 +1,92 @@ +apiVersion: v1 +kind: Service +metadata: + name: immich-machine-learning + namespace: immich +spec: + selector: + app: immich-machine-learning + ports: + - name: http + port: 3003 + targetPort: 3003 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: immich-machine-learning-deployment + namespace: immich + labels: + app: immich-machine-learning +spec: + replicas: 1 + selector: + matchLabels: + app: immich-machine-learning + template: + metadata: + labels: + app: immich-machine-learning + spec: + containers: + - name: immich-machine-learning + image: ghcr.io/immich-app/immich-machine-learning:v3 + envFrom: + - configMapRef: + name: immich-config + - secretRef: + name: immich-secrets + ports: + - name: http + containerPort: 3003 + volumeMounts: + - name: model-cache + mountPath: /cache + # The first request pulls a model over the internet, so a cold start + # is slower than a container start. + startupProbe: + httpGet: + path: /ping + port: http + failureThreshold: 60 + periodSeconds: 5 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /ping + port: http + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /ping + port: http + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + # Same reasoning as the server: the request covers the idle cost + # only, because the node has no spare cores to schedule against. + # Recognition is the burst - a busy import wants both cores. + resources: + requests: + cpu: "100m" + memory: "1Gi" + limits: + cpu: "2000m" + memory: "3Gi" + volumes: + - name: model-cache + persistentVolumeClaim: + claimName: immich-model-cache-pvc +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: immich-model-cache-pvc + namespace: immich +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/immich/k8s/namespace.yaml b/immich/k8s/namespace.yaml new file mode 100644 index 0000000..d2cda34 --- /dev/null +++ b/immich/k8s/namespace.yaml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=kubernetes +apiVersion: v1 +kind: Namespace +metadata: + name: immich diff --git a/immich/k8s/postgres.yaml b/immich/k8s/postgres.yaml new file mode 100644 index 0000000..f8a6024 --- /dev/null +++ b/immich/k8s/postgres.yaml @@ -0,0 +1,138 @@ +# Immich's own database, separate from the shared postgres in the database +# namespace. It has to be separate: v3 checks the VectorChord version at startup +# and refuses to boot without it, VectorChord needs its .so in +# shared_preload_libraries, and that can only be read when postmaster starts. +# So the shared instance would have to be rebuilt on a custom image carrying +# vchord and restarted - for every consumer of it (authentik, gitea, netbox, +# netronome, penpot, statuspage). Not worth it for one photo library. +apiVersion: v1 +kind: Service +metadata: + name: immich-postgres + namespace: immich + labels: + app: immich-postgres +spec: + selector: + app: immich-postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: immich-postgres + namespace: immich + labels: + app: immich-postgres +spec: + serviceName: immich-postgres + replicas: 1 + selector: + matchLabels: + app: immich-postgres + template: + metadata: + labels: + app: immich-postgres + spec: + containers: + - name: postgres + # v3.x expects vchord for its vector work and vectors (pgvecto.rs) + # for some index types. This image ships both and preloads them, plus + # its own shared_buffers and wal settings, through + # /etc/postgresql/postgresql.conf - which its entrypoint reaches via + # `postgres -c config_file=...` in the image CMD. + # + # So there is deliberately no `command:` here. Overriding it replaces + # that config_file, and it also loses the step where the entrypoint + # drops from root to the postgres user: postmaster then starts as + # root and refuses to run. + image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23 + env: + - name: POSTGRES_USER + value: immich + - name: POSTGRES_DB + value: immich + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: immich-secrets + key: DB_PASSWORD + # Only read when the data directory is empty, so the checksums are + # decided here and never again. + - name: POSTGRES_INITDB_ARGS + value: --data-checksums + # The postgres-data volume lives on sdc, which is rotational. The + # SSD template is the default; HDD only changes the planner costs + # (effective_io_concurrency, random_page_cost), nothing structural. + - name: DB_STORAGE_TYPE + value: HDD + - name: TZ + valueFrom: + configMapKeyRef: + name: immich-config + key: TZ + ports: + - name: postgres + containerPort: 5432 + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + # The upstream compose file asks docker for 128mb of shm. Kubernetes + # gives every container 64mb, which is not what postmaster expects + # for parallel query workers and the WAL writer. + - name: shm + mountPath: /dev/shm + # Probes use a generous timeout on purpose: the data lives on a + # rotational disk on a loaded single node, and pg_isready can take + # seconds during WAL recovery. A 1s timeout kills the container + # mid-recovery and restarts the spiral. + startupProbe: + exec: + command: ["sh", "-c", "pg_isready -U immich -d immich"] + failureThreshold: 60 + periodSeconds: 5 + timeoutSeconds: 5 + readinessProbe: + exec: + command: ["sh", "-c", "pg_isready -U immich -d immich"] + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: ["sh", "-c", "pg_isready -U immich -d immich"] + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + # The image template sets shared_buffers to 512MB, and the vchord and + # vectors workers are Rust binaries with a real RSS footprint on top + # of postmaster, checkpointer and friends. 1Gi was enough to start + # the server but the vectors worker kept dying in it, so the limit + # sits at 2Gi. The request stays at the idle cost. + resources: + requests: + cpu: "50m" + memory: "256Mi" + limits: + cpu: "1000m" + memory: "2Gi" + volumes: + - name: shm + emptyDir: + medium: Memory + sizeLimit: 128Mi + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: ["ReadWriteOnce"] + # Retain: this is the metadata for a library that only exists in one + # place, and local-path cannot expand a bound volume, so this size has + # to hold until the library is rebuilt or dumped elsewhere. + storageClassName: local-path-retain + resources: + requests: + storage: 32Gi diff --git a/immich/k8s/secrets.yaml.example b/immich/k8s/secrets.yaml.example new file mode 100644 index 0000000..a9fc45a --- /dev/null +++ b/immich/k8s/secrets.yaml.example @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: immich-secrets + namespace: immich +type: Opaque +stringData: + # Creates the immich superuser in this namespace's own postgres on first + # boot, and is the same value the server connects with. Nothing outside the + # immich namespace needs it. Letters and digits only: immich reads this into + # a connection string. + DB_PASSWORD: "changeme" + REDIS_PASSWORD: "changeme" diff --git a/immich/k8s/valkey.yaml b/immich/k8s/valkey.yaml new file mode 100644 index 0000000..0a408eb --- /dev/null +++ b/immich/k8s/valkey.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: Service +metadata: + name: immich-valkey + namespace: immich + labels: + app: immich-valkey +spec: + clusterIP: None + selector: + app: immich-valkey + ports: + - name: valkey + port: 6379 + targetPort: valkey +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: immich-valkey + namespace: immich + labels: + app: immich-valkey +spec: + serviceName: immich-valkey + replicas: 1 + selector: + matchLabels: + app: immich-valkey + template: + metadata: + labels: + app: immich-valkey + spec: + containers: + - name: valkey + image: docker.io/valkey/valkey:9.1.2-alpine + command: + - sh + - -c + - valkey-server --appendonly yes --save 30 1 --loglevel warning --requirepass "$REDIS_PASSWORD" + envFrom: + - secretRef: + name: immich-secrets + ports: + - name: valkey + containerPort: 6379 + volumeMounts: + - name: valkey-data + mountPath: /data + # Same reasoning as postgres: 1s probe timeouts flap on a loaded + # single node with rotational storage. + startupProbe: + exec: + command: ["sh", "-c", 'valkey-cli --pass "$REDIS_PASSWORD" ping | grep -q PONG'] + failureThreshold: 20 + periodSeconds: 5 + timeoutSeconds: 5 + readinessProbe: + exec: + command: ["sh", "-c", 'valkey-cli --pass "$REDIS_PASSWORD" ping | grep -q PONG'] + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: ["sh", "-c", 'valkey-cli --pass "$REDIS_PASSWORD" ping | grep -q PONG'] + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 5 + resources: + requests: + cpu: "25m" + memory: "64Mi" + limits: + cpu: "250m" + memory: "256Mi" + volumeClaimTemplates: + - metadata: + name: valkey-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi