Octelium documentation · Latest

Self-Hosting Immich for Photos and Videos

Immich is a popular open source, self-hosted photo and video management solution, and a common self-hosted alternative to Google Photos and iCloud Photos. This guide shows you how to deploy Immich via its official Helm chart on the same underlying Kubernetes cluster that is running the Octelium Cluster, and then serve it as an Octelium Service. Octelium provides the following:

  • Private client-based access over WireGuard/QUIC via the octelium connect command (read more here) from your laptops and desktops, including bulk uploads via the Immich CLI.

  • Public clientless BeyondCorp access (read more here) from any web browser, where your Users authenticate via OpenID Connect, SAML 2.0 or GitHub IdentityProviders (read more here) as well as Passkeys before they are able to reach Immich at all.

  • Clientless access for the Immich mobile app, including automatic background backups, via per-device access tokens.

  • Immich's own login and onboarding pages are never exposed to anonymous internet traffic.

  • Identity-based, L7 aware access control on a per-request basis according to the HTTP request's path, method, headers, etc... via policy-as-code with CEL and OPA (read more about Policies and access control here).

  • OpenTelemetry-native, identity-based, L7 aware visibility and auditing (read more here).

note

If you installed the Cluster via the quick installation guide, you can simply use the command export KUBECONFIG="/etc/rancher/k3s/k3s.yaml" in your Cluster VM/VPS before running the kubectl and helm commands below.

Install Immich

Immich consists of a server, a machine learning container that powers the smart search and facial recognition features, a Valkey (i.e. Redis-compatible) instance and a PostgreSQL database with the VectorChord extension. The official Immich Helm chart deploys the first three components, while it expects you to provide the PostgreSQL database as well as the persistent volume that stores your photo and video library yourself.

Install CloudNativePG

The Immich chart recommends deploying the PostgreSQL database via CloudNativePG, the open source PostgreSQL operator for Kubernetes. First, we install the CloudNativePG operator via its Helm chart as follows:

helm repo add cnpg https://cloudnative-pg.github.io/charts helm repo update helm upgrade --install cnpg --namespace cnpg-system --create-namespace cnpg/cloudnative-pg

Create the Database and the Library Volume

Now we create a dedicated Kubernetes namespace for Immich as follows:

kubectl create namespace immich

Now we define the persistent volume claim of the Immich library as well as the PostgreSQL database in a storage.yaml file as follows:

apiVersion: v1 kind: PersistentVolumeClaim metadata: name: immich-library namespace: immich spec: accessModes: - ReadWriteOnce resources: requests: storage: 100Gi --- apiVersion: postgresql.cnpg.io/v1 kind: Cluster metadata: name: immich-database namespace: immich spec: instances: 1 imageName: ghcr.io/tensorchord/cloudnative-vectorchord:18.6-1.1.1 postgresql: shared_preload_libraries: - vchord.so bootstrap: initdb: database: immich owner: immich postInitApplicationSQL: - CREATE EXTENSION IF NOT EXISTS vchord CASCADE; - CREATE EXTENSION IF NOT EXISTS earthdistance CASCADE; storage: size: 10Gi

The cloudnative-vectorchord image is the CloudNativePG PostgreSQL image with the VectorChord extension installed, which is loaded via the shared_preload_libraries field. The postInitApplicationSQL queries are executed once as the PostgreSQL superuser upon the creation of the database. They create the extensions that require superuser permissions, so that Immich itself can connect as the ordinary immich owner of the database without needing superuser permissions (read more here). CloudNativePG automatically generates the database password and stores the connection details in the immich-database-app Kubernetes secret.

Now apply the creation of both resources and wait for the database to become ready as follows:

kubectl apply -f /PATH/TO/STORAGE.YAML kubectl wait --for=condition=Ready cluster/immich-database -n immich --timeout=10m
note

Both the persistent volume claim and the database use the default storage class of your Kubernetes cluster. For example, the k3s cluster installed by the quick installation guide uses the local-path storage class which stores the volumes on the node's own disk under the /var/lib/rancher/k3s/storage directory. If you want to store your library somewhere else, such as on a NAS, you can explicitly set the storageClassName field of the persistent volume claim.

note

Immich's built-in automatic database backups use pg_dumpall, which requires superuser permissions (read more here). Since Immich connects as an ordinary database owner in this setup, you should instead back up the database via CloudNativePG's own backup mechanisms (read more here).

Install the Immich Chart

Now we create a values.yaml file for the Immich Helm chart (see the chart here) that uses the library volume, enables the bundled Valkey instance, and reads the database connection details from the immich-database-app secret as follows:

controllers: main: containers: main: image: tag: v3.2.0 immich: persistence: library: existingClaim: immich-library valkey: enabled: true server: controllers: main: containers: main: env: DB_HOSTNAME: valueFrom: secretKeyRef: name: immich-database-app key: host DB_USERNAME: valueFrom: secretKeyRef: name: immich-database-app key: user DB_PASSWORD: valueFrom: secretKeyRef: name: immich-database-app key: password DB_DATABASE_NAME: valueFrom: secretKeyRef: name: immich-database-app key: dbname

Now install the chart and wait for the Immich server to become ready as follows:

helm install immich oci://ghcr.io/immich-app/immich-charts/immich --version 0.13.2 --namespace immich -f /PATH/TO/VALUES.YAML kubectl rollout status deployment/immich-server -n immich --timeout=10m
note

The Immich chart does not update its default image tag with every Immich release. Therefore, it is recommended to explicitly pin the Immich version via the image.tag value as shown above, and to upgrade it deliberately after reading the Immich release notes. Note that all the Immich components share the same version.

note

The machine learning container is the most resource-hungry component of Immich. If you are running the Cluster on a small VM or a single-board computer, you can disable it via the machine-learning.enabled value at the cost of losing smart search and facial recognition.

The chart creates the immich-server Kubernetes service in the immich Kubernetes namespace which listens over the port 2283.

Create the Service

Now we create an Octelium Service for the Immich server whose upstream is the immich-server Kubernetes service. We also define a family Group (read more about Groups here) where only its members are allowed to access the Service as follows:

kind: Group metadata: name: family spec: {} --- kind: Service metadata: name: immich spec: mode: WEB isPublic: true config: upstream: url: http://immich-server.immich.svc:2283 authorization: inlinePolicies: - spec: rules: - effect: ALLOW condition: match: '"family" in ctx.user.spec.groups'

The isPublic field enables the clientless BeyondCorp access to the Service (read more here), while the client-based access is always available. The WEB mode simply marks the Service as a web app in the web Portal (read more here) and is otherwise identical to the HTTP mode. The Service port is automatically inferred from the upstream URL, which means that the Service also listens over the port 2283 (read more here).

Now add your Users to the family Group. Here is an example:

kind: User metadata: name: john spec: type: HUMAN email: john@example.com groups: ["family"]

You can now apply the creation of the Group, the Service and the Users via the octeliumctl apply command (read more here) as follows:

octeliumctl apply /PATH/TO/YAML_FILE_OR_PARENT_DIRECTORY
note

We intentionally keep the default HTTP header behavior of the Service. By default, the Service drops all the Forwarded, X-Forwarded-* and X-Real-IP request headers (read more here). Immich trusts the X-Forwarded-Proto header of proxies with private IP addresses by default. For a public Service, enabling the OBFUSCATE forwarded mode or adding your own X-Forwarded-Proto: https header would make Immich believe that every request, including the client-based plaintext HTTP requests, arrives over HTTPS. Immich would then set its session cookies with the Secure attribute, which browsers refuse to store over plaintext HTTP, and you could no longer login via the client-based mode.

note

By default, neither the Cluster's Ingress nor the Service limits the size of the request body or enforces a total timeout per request, and WebSocket connections, which Immich uses for real-time updates, are supported out of the box. Therefore, you do not need to tune any upload limits for your large videos as you would typically do with many reverse proxies. However, you should not enable the request body buffering (read more here) for this Service, since it buffers the entire body of every upload in memory.

Now open Immich, either via the client-based or the clientless mode as shown in the following sections, and create the Immich admin account via the onboarding page. Unlike exposing Immich directly to the internet, only your authorized Users are able to reach that onboarding page in the first place. You might also want to set the URL https://immich.<DOMAIN> in Immich's Administration > Settings > Server Settings > External domain setting, so that Immich uses the public URL of the Service in its shared links and email notifications.

Client-Based Access

Once connected to the Cluster via the octelium connect command (read more here), you can simply visit Immich from your browser at the URL http://immich:2283.

You can also connect via the rootless mode and map the Service to your host's localhost (read more here) as follows:

# Connect as non-root/unprivileged OS user octelium connect -p immich:2283 # Now access the Service at localhost curl http://localhost:2283/api/server/ping

The client-based mode is especially useful for bulk uploads of your existing photo and video library from your laptop or desktop via the Immich CLI (read more here). First, create an Immich API key via your Immich Account Settings > API Keys, and then login and upload your directory as follows:

npm i -g @immich/cli immich login http://immich:2283/api <IMMICH_API_KEY> immich upload --recursive /PATH/TO/PHOTOS

The Immich CLI authenticates to Immich via the x-api-key header which is passed as is to Immich, while the Cluster authenticates your connected Session itself. You can also use the --watch flag to keep watching the directory and automatically upload new files.

Clientless Access

Web Browsers

Authorized HUMAN Users can access Immich via the clientless BeyondCorp mode directly from their browsers at the URL https://immich.<DOMAIN> without having to install any client. An unauthenticated User is first redirected to the Cluster's login page in order to authenticate via an IdentityProvider (read more here). There are currently 3 types:

  • GitHub OAuth IdentityProvider as shown in detail here

  • OpenID Connect IdentityProviders (e.g. Okta, Auth0, etc...) as shown here.

  • SAML 2.0 IdentityProviders (e.g. Okta, Entra ID, etc...) as shown here.

Furthermore, you can register a FIDO2 Authenticator (e.g. Yubikeys) in order to directly login later via Passkey (read more here) without having to use an IdentityProvider.

Once authenticated to the Cluster, the User then logs in to Immich itself with their Immich account.

note

Immich also supports OAuth/OpenID Connect login (read more here). You can use the same self-hosted identity provider that you use to login to the Cluster, such as Authentik (read more here) or Keycloak (read more here), to login to Immich as well.

Mobile App

The Immich mobile app is a native app rather than a web browser, so it cannot go through the Cluster's browser-based login. Instead, every phone can use its own access token Credential (read more here) that the app sends with each request via a custom header. Access token Credentials are issued to WORKLOAD Users (read more about User types here), so we create a dedicated User for every phone as follows:

kind: User metadata: name: john-phone spec: type: WORKLOAD groups: ["family"] session: clientlessDuration: days: 90 accessTokenDuration: days: 90 authorization: inlinePolicies: - spec: rules: - effect: DENY condition: match: ctx.service.metadata.name != "immich.default"

By default, an access token is only valid for 4 hours, which is why we explicitly set the Session and access token durations of the User (read more here). The inline Policy makes sure that the phone's identity can only be used to access the immich Service and nothing else in the Cluster, even if the phone is lost or stolen. After applying the User via octeliumctl apply, create the access token Credential as follows:

octeliumctl create cred --type access-token --user john-phone john-phone

Now configure the Immich app on the phone as follows:

  1. On the login screen of the app, tap on Settings, then go to Advanced > Custom proxy headers.

  2. Add a header whose name is X-Octelium-Auth and whose value is the access token.

  3. Go back to the login screen, set the server URL to https://immich.<DOMAIN> and then login with your Immich account.

The app sends the custom header with every request, including the automatic background backups. The Cluster authenticates the request via the X-Octelium-Auth header and then removes it before the request reaches Immich, while Immich keeps authenticating the app via its own session cookie.

note

We use the X-Octelium-Auth header (read more here) rather than the Authorization header in order to keep the Cluster's credentials completely separate from Immich's own. All X-Octelium-* headers as well as the Cluster's own cookies are always removed before the request is forwarded to the upstream.

Once the access token expires, or whenever you suspect that it has leaked, you can rotate the Credential (read more here) and update the header value in the app as follows:

octeliumctl create cred --rotate john-phone

You can also immediately revoke the phone's access by disabling its User (read more here).

Access Control

Since every request to Immich is authorized by the Service, you can also control access at the HTTP layer on top of Immich's own permissions (read more about Policies and access control here). Here is an example where the members of the guests Group (e.g. relatives that you share some albums with) can browse Immich, but are not allowed to upload or delete assets via the Immich API:

kind: Group metadata: name: guests spec: {} --- kind: Service metadata: name: immich spec: mode: WEB isPublic: true config: upstream: url: http://immich-server.immich.svc:2283 authorization: inlinePolicies: - spec: rules: - effect: ALLOW condition: any: of: - match: '"family" in ctx.user.spec.groups' - match: '"guests" in ctx.user.spec.groups' - effect: DENY condition: all: of: - match: '"guests" in ctx.user.spec.groups' - match: ctx.request.http.path == "/api/assets" - match: ctx.request.http.method in ["POST", "DELETE"]

Uploading an asset is a POST request and deleting assets is a DELETE request to the /api/assets path. Since DENY rules override ALLOW rules of the same priority (read more here), such requests of the guests Group members are denied before they reach Immich. You can read more about HTTP-specific Policies here.

Visibility

Octelium also provides OpenTelemetry-ready, application-layer L7 aware visibility and access logging in real time (read more about visibility here). Here is an example of an access Log of a photo uploaded by the background backup of the john-phone User:

{ "apiVersion": "core/v1", "entry": { "common": { // Omitted for the sake of brevity of the example }, "info": { "http": { "request": { "path": "/api/assets", "userAgent": "immich-android/3.2.0", "method": "POST", "uri": "/api/assets" }, "response": { "code": 201, "bodyBytes": "64", "contentType": "application/json; charset=utf-8" }, "httpVersion": "HTTP11" } } }, "kind": "AccessLog", "metadata": { // Omitted for the sake of brevity of the example } }

Here are a few more features that you might be interested in:

  • Using Octelium as a self-hosted exit node and VPN for your devices (read more here).

  • Deploying your own containers as Services (read more about managed containers here).

  • Request/response header manipulation (read more here).

  • Dynamic routing to different upstreams according to identity and context (read more here).

  • Exposing your homelab's DNS server, such as Pi-hole, to all of your connected Users (read more here).