diff --git a/content/docs/guides/img/immich/immich-login.png b/content/docs/guides/img/immich/immich-login.png
new file mode 100644
index 000000000..a3d35be44
Binary files /dev/null and b/content/docs/guides/img/immich/immich-login.png differ
diff --git a/content/docs/guides/immich.mdx b/content/docs/guides/immich.mdx
new file mode 100644
index 000000000..20effc85b
--- /dev/null
+++ b/content/docs/guides/immich.mdx
@@ -0,0 +1,152 @@
+---
+title: Secure Immich with Pomerium
+sidebar_label: Immich
+lang: en-US
+keywords:
+ [pomerium, immich, sso, oidc, identity aware proxy, self-hosted, photos]
+description: Put self-hosted Immich behind Pomerium so every browser request is authenticated and authorized at the front door before it reaches your photo library.
+# cSpell:ignore immich vectorchord valkey
+---
+
+import TabItem from '@theme/TabItem';
+import Tabs from '@theme/Tabs';
+
+import Config from '/content/examples/guides/immich/config.yaml.md';
+import Compose from '/content/examples/guides/immich/docker-compose.yaml.md';
+
+# Secure Immich with Pomerium
+
+## What this guide does
+
+Put a self-hosted [Immich](https://immich.app/) instance behind Pomerium so every browser request to its web interface is authenticated against your identity provider (IdP) and checked against your route policy before it reaches Immich. You get centralized single sign-on (SSO), one place to express group and domain policy, and an audit trail of who reached the service. Immich keeps its own login and accounts on top; Pomerium decides who can reach them at all.
+
+```mermaid
+flowchart LR
+ Browser --> Pomerium["Pomerium
SSO + route policy"]
+ Pomerium -.->|"sign in"| IdP[Identity provider]
+ Pomerium --> Immich["Immich
web + API"]
+ Mobile["Mobile / API client"] -->|"access token"| Immich
+```
+
+## When to use this guide
+
+Use it when you run self-hosted Immich and don't want its web interface exposed directly to the internet. Pomerium makes the access decision; Immich continues to manage albums, sharing, and its own user sessions behind it.
+
+This guide covers the web interface. Immich's mobile apps and API clients authenticate with Immich's own access tokens and never perform a browser SSO redirect, so a route that requires Pomerium SSO blocks them. Either serve the API or mobile hostname on a separate [public access](/docs/reference/routes/public-access) route (not identity-protected, so Immich's token auth is then the only control), or keep that host off Pomerium and reach it over a virtual private network (VPN). [Security considerations](#security-considerations) walks through the trade-offs.
+
+## Prerequisites
+
+- [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/)
+- For the Pomerium Zero path: a [Pomerium Zero](https://console.pomerium.app) account with its Pomerium instance running locally via the [Quickstart](/docs/get-started/quickstart) Compose file; the route uses the starter domain that comes with it
+- For the Pomerium Core path: a domain you control for the Immich route (this guide uses `immich.yourdomain.com`), with DNS pointed at the host running Pomerium and ports 80 and 443 reachable so `autocert` can provision certificates; the Compose file below runs Pomerium itself
+
+:::tip Prefer to self-host the identity provider?
+
+This guide uses the hosted authenticate service so you don't have to run an IdP. To run your own instead, follow [Keycloak + Pomerium](/docs/integrations/user-identity/oidc) and swap the `authenticate_service_url` / `idp_*` settings into the config below.
+
+:::
+
+## Configure Pomerium
+
+
+
+
+In the [Zero Console](https://console.pomerium.app):
+
+1. Create a **Route**. In **From**, enter `https://immich.`; in **To**, enter `http://immich-server:2283`.
+2. Set the policy to scope access to who should reach Immich (for example, **Any Authenticated User** or a specific group or domain).
+3. On the route's settings, enable **Allow WebSockets** and **Preserve Host Header**. Immich's reverse-proxy guidance expects WebSocket upgrades and the original host to reach the server.
+4. Raise the request **Timeout** (or set it to 0 for no limit), and set a generous **Idle Timeout** such as `600s`, so large photo and video uploads and original-quality downloads are not cut off mid-transfer.
+
+Zero manages the route's TLS certificate behind your starter domain, so there's nothing else to configure on the Pomerium side.
+
+
+
+
+Create a `config.yaml`. It routes `immich.yourdomain.com` to the Immich server container, preserves the public host header, allows WebSockets, and lifts the request time limit so large uploads and downloads are not cut off.
+
+
+
+Replace `immich.yourdomain.com` with your domain and `you@example.com` with the email (or switch to a group or domain match) that should be allowed through.
+
+
+
+
+## Configure Immich
+
+Immich keeps its own login and account database. The first time you reach it through Pomerium, Immich serves its own onboarding screen where you create the initial admin account; after that it enforces its own sessions on top of Pomerium's SSO.
+
+This minimal stack includes what the Immich web/API server needs to boot behind Pomerium:
+
+- A connection to its database. Immich ships a specific PostgreSQL image that bundles the vector-search extension it depends on. Set `DB_HOSTNAME`, `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` to match the database service, and set the matching `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` on the database.
+- A connection to Redis (Valkey) for its job queue, via `REDIS_HOSTNAME`.
+- A media volume for uploaded photos and videos.
+
+Generate a unique database password and use the same value for `DB_PASSWORD` and `POSTGRES_PASSWORD`.
+
+It does not include Immich's `immich-machine-learning` service; add it from the official Immich release Compose file if you need smart search or face recognition.
+
+:::caution Use Immich's database image, not stock Postgres
+
+Immich requires the [VectorChord](https://docs.immich.app/administration/postgres-standalone/) PostgreSQL image it ships (`ghcr.io/immich-app/postgres:...` in the Compose file). A stock `postgres` image crashes `immich-server` on first boot because the vector-search extension is missing. Pin the exact image Immich publishes for your release.
+
+:::
+
+## Run the stack
+
+The Compose file runs Pomerium Core together with the Immich server, its VectorChord PostgreSQL database, and Redis (Valkey). Pomerium publishes ports 80 and 443 for the protected route; the Immich server publishes no host ports, and Pomerium reaches it by service name on the Compose project network. For Zero, drop the `pomerium` service and use the `compose.yaml` from the [Quickstart](/docs/get-started/quickstart) with your `POMERIUM_ZERO_TOKEN`, keeping the Immich services below; put those services on the same Docker network as the Quickstart's `pomerium` service (the Quickstart names it `main`) so Pomerium can resolve `immich-server` by name.
+
+
+
+Start it:
+
+```bash
+docker compose up -d
+```
+
+On first boot the Immich server runs database migrations against the VectorChord database, which can take a minute or two before it answers requests. Watch `docker compose ps` until `immich-server` reports `healthy`, or follow `docker compose logs -f immich-server`.
+
+To stop the stack, run `docker compose down`. Add `-v` only if you mean it: that flag deletes the named volumes, which wipes the Immich database and your entire photo library.
+
+This guide was last tested with Immich v2.7.5 and Pomerium v0.32.7. Newer Immich releases pin a different VectorChord PostgreSQL image, so take the database image from the [release matching your Immich version](https://github.com/immich-app/immich/releases) when you upgrade.
+
+## Verify the setup
+
+1. **The route requires authentication.** In a fresh browser, open `https://immich.yourdomain.com`. You should be redirected to sign in through Pomerium, not straight to Immich.
+2. **An allowed user gets in.** Sign in with a user your policy allows. Pomerium redirects you back and Immich's own onboarding or login screen loads.
+
+
+
+3. **Immich answers through Pomerium.** In the same browser where you signed in, open `https://immich.yourdomain.com/api/server/ping`. The browser carries your Pomerium session, so the request reaches Immich and returns `{"res":"pong"}`. A plain `curl` with no Pomerium session is redirected to sign in instead. On Pomerium Zero or Enterprise, you can script the check by attaching a [Pomerium service account](/docs/capabilities/service-accounts) token that your route policy allows.
+
+Pomerium decides who reaches Immich; Immich runs its own login on top. Immich's admin onboarding and first-run setup are Immich's concern, not Pomerium's.
+
+## Common failure modes
+
+- **`502` or `503` right after `docker compose up`.** The Immich server is still running first-boot database migrations. Wait for the container to report `healthy`; watch `docker compose logs -f immich-server`.
+- **`immich-server` restarts in a crash loop with a missing-extension error.** The database is a stock Postgres image instead of Immich's VectorChord image. Switch the database service to the `ghcr.io/immich-app/postgres:...` image Immich ships. If this happened on a fresh test stack, remove the database volume and reinitialize it; for an existing library, back up the database and follow Immich's migration or recovery docs before deleting any volume.
+- **Uploads of large videos fail or time out.** The proxy is cutting the request short. Set the route `timeout` to `0s` (and a generous `idle_timeout`) as shown in the config so long transfers aren't capped.
+- **Redirect loop or certificate errors.** Make sure DNS for `immich.yourdomain.com` points at Pomerium and that Pomerium can obtain a TLS certificate. On the Core path, `autocert` needs ports 80 and 443 reachable for Let's Encrypt; Zero manages certificates for you.
+
+## Security considerations
+
+Immich runs its own authentication, so this guide puts Pomerium in front of that login as the front door. (Immich can also sign users in itself with native OIDC against your identity provider; see [Next steps](#next-steps).) The trust boundary depends on Pomerium being the only path to the web interface, and on understanding which clients the route actually protects.
+
+- **Don't expose Immich directly.** Only Pomerium should reach `immich-server:2283`. Keep the Immich server off published host ports and on the Docker network Pomerium uses to reach it, so the policy can't be bypassed by hitting Immich's port directly.
+- **Scope the route policy** (group or domain) to who should have any access to Immich at all. Immich's own per-user albums and sharing still apply on top of that.
+- **Pomerium's SSO covers the web browser, not token-based clients.** The Immich mobile apps and API or command-line clients authenticate with their own Immich access tokens and never perform a browser SSO redirect. If the host the mobile app talks to requires Pomerium SSO, the app breaks: it has no way to satisfy the interactive redirect. Make a deliberate choice for that host. Either serve the API or mobile hostname on a separate route with [public access](/docs/reference/routes/public-access), or keep that host off Pomerium entirely and reach it over a VPN. On a public-access route, Pomerium will not authenticate or authorize matching requests, so Immich's own token auth is the only control; scope the host or path as narrowly as Immich supports.
+
+Each access channel is protected by a different control:
+
+| Access channel | What gates it | Credential the client presents |
+| --- | --- | --- |
+| Web interface in a browser | Pomerium route policy, then Immich's own login | Pomerium SSO session, then an Immich session |
+| Mobile apps | Immich's own authentication (reach it over a public access route or a VPN) | Immich access token from the app's login |
+| API and CLI clients | Immich's own authentication (reach it over a public access route or a VPN) | Immich API key |
+
+## Next steps
+
+- **Go further: single sign-on into Immich.** Immich supports [native OIDC](https://docs.immich.app/administration/oauth/). Point it at the same identity provider Pomerium uses, turn on Auto Register, and users land already signed in instead of meeting a second Immich login. This is Immich doing its own OIDC, not something Pomerium forwards, so it also covers the mobile app's login (which Pomerium's browser-based SSO cannot).
+- [Build policies](/docs/get-started/fundamentals/zero/zero-build-policies)
+- [Set route timeouts](/docs/reference/routes/timeouts)
+- [Custom domains](/docs/capabilities/custom-domains)
diff --git a/content/examples/guides/immich/config.yaml b/content/examples/guides/immich/config.yaml
new file mode 100644
index 000000000..db121a823
--- /dev/null
+++ b/content/examples/guides/immich/config.yaml
@@ -0,0 +1,23 @@
+# Pomerium Core configuration for Immich. Uses the hosted authenticate service, so
+# you don't run your own identity provider. To self-host the IdP, see the Keycloak
+# guide: https://www.pomerium.com/docs/integrations/user-identity/oidc
+authenticate_service_url: https://authenticate.pomerium.app
+
+# Obtain TLS certificates automatically from Let's Encrypt.
+autocert: true
+
+routes:
+ - from: https://immich.yourdomain.com
+ to: http://immich-server:2283
+ allow_websockets: true
+ preserve_host_header: true
+ # Photo and video uploads (and original-quality downloads) can be large and
+ # long-running, so don't cap total request time at the proxy.
+ timeout: 0s
+ idle_timeout: 600s
+ policy:
+ - allow:
+ or:
+ - email:
+ # Replace with the users or domain you want to allow
+ is: you@example.com
diff --git a/content/examples/guides/immich/config.yaml.md b/content/examples/guides/immich/config.yaml.md
new file mode 100644
index 000000000..9811fa62c
--- /dev/null
+++ b/content/examples/guides/immich/config.yaml.md
@@ -0,0 +1,25 @@
+```yaml title="config.yaml"
+# Pomerium Core configuration for Immich. Uses the hosted authenticate service, so
+# you don't run your own identity provider. To self-host the IdP, see the Keycloak
+# guide: https://www.pomerium.com/docs/integrations/user-identity/oidc
+authenticate_service_url: https://authenticate.pomerium.app
+
+# Obtain TLS certificates automatically from Let's Encrypt.
+autocert: true
+
+routes:
+ - from: https://immich.yourdomain.com
+ to: http://immich-server:2283
+ allow_websockets: true
+ preserve_host_header: true
+ # Photo and video uploads (and original-quality downloads) can be large and
+ # long-running, so don't cap total request time at the proxy.
+ timeout: 0s
+ idle_timeout: 600s
+ policy:
+ - allow:
+ or:
+ - email:
+ # Replace with the users or domain you want to allow
+ is: you@example.com
+```
diff --git a/content/examples/guides/immich/docker-compose.yaml b/content/examples/guides/immich/docker-compose.yaml
new file mode 100644
index 000000000..0ca4e4c6a
--- /dev/null
+++ b/content/examples/guides/immich/docker-compose.yaml
@@ -0,0 +1,49 @@
+services:
+ pomerium:
+ image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c
+ volumes:
+ - ./config.yaml:/pomerium/config.yaml:ro
+ - pomerium-cache:/data
+ ports:
+ - 443:443
+ - 80:80
+ restart: always
+
+ immich-server:
+ image: ghcr.io/immich-app/immich-server:v2.7.5@sha256:c15bff75068effb03f4355997d03dc7e0fc58720c2b54ad6f7f10d1bc57efaa5
+ environment:
+ DB_HOSTNAME: database
+ DB_USERNAME: postgres
+ DB_PASSWORD: change-this-database-password
+ DB_DATABASE_NAME: immich
+ REDIS_HOSTNAME: redis
+ volumes:
+ - immich-library:/data
+ depends_on:
+ - database
+ - redis
+ restart: always
+
+ # Immich requires this VectorChord/pgvecto-rs Postgres image. A stock
+ # postgres image crashes immich-server on first boot because the vector
+ # search extension is missing, so pin the exact image Immich ships.
+ database:
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: change-this-database-password
+ POSTGRES_DB: immich
+ POSTGRES_INITDB_ARGS: '--data-checksums'
+ volumes:
+ - immich-db:/var/lib/postgresql/data
+ shm_size: 128mb
+ restart: always
+
+ redis:
+ image: docker.io/valkey/valkey:9.0.3@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
+ restart: always
+
+volumes:
+ pomerium-cache:
+ immich-library:
+ immich-db:
diff --git a/content/examples/guides/immich/docker-compose.yaml.md b/content/examples/guides/immich/docker-compose.yaml.md
new file mode 100644
index 000000000..be9928c8f
--- /dev/null
+++ b/content/examples/guides/immich/docker-compose.yaml.md
@@ -0,0 +1,51 @@
+```yaml title="docker-compose.yaml"
+services:
+ pomerium:
+ image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c
+ volumes:
+ - ./config.yaml:/pomerium/config.yaml:ro
+ - pomerium-cache:/data
+ ports:
+ - 443:443
+ - 80:80
+ restart: always
+
+ immich-server:
+ image: ghcr.io/immich-app/immich-server:v2.7.5@sha256:c15bff75068effb03f4355997d03dc7e0fc58720c2b54ad6f7f10d1bc57efaa5
+ environment:
+ DB_HOSTNAME: database
+ DB_USERNAME: postgres
+ DB_PASSWORD: change-this-database-password
+ DB_DATABASE_NAME: immich
+ REDIS_HOSTNAME: redis
+ volumes:
+ - immich-library:/data
+ depends_on:
+ - database
+ - redis
+ restart: always
+
+ # Immich requires this VectorChord/pgvecto-rs Postgres image. A stock
+ # postgres image crashes immich-server on first boot because the vector
+ # search extension is missing, so pin the exact image Immich ships.
+ database:
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: change-this-database-password
+ POSTGRES_DB: immich
+ POSTGRES_INITDB_ARGS: '--data-checksums'
+ volumes:
+ - immich-db:/var/lib/postgresql/data
+ shm_size: 128mb
+ restart: always
+
+ redis:
+ image: docker.io/valkey/valkey:9.0.3@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
+ restart: always
+
+volumes:
+ pomerium-cache:
+ immich-library:
+ immich-db:
+```
diff --git a/content/examples/guides/immich/validate/assert.spec.ts b/content/examples/guides/immich/validate/assert.spec.ts
new file mode 100644
index 000000000..517e34e10
--- /dev/null
+++ b/content/examples/guides/immich/validate/assert.spec.ts
@@ -0,0 +1,61 @@
+import { test, expect } from "@playwright/test";
+import { login, alice } from "../lib/authn";
+import { shot } from "../lib/shot";
+
+// Real end-to-end test for the Immich front-door gate. Pomerium makes the access
+// decision; Immich keeps its own login on top. This drives the whole chain: an
+// unauthenticated request is bounced to the IdP, an allowed user passes the gate,
+// then Immich's own API confirms the request reached Immich through Pomerium, and
+// the Immich web login renders behind the gate.
+const BASE = process.env.POMERIUM_URL as string;
+
+test("unauthenticated request is redirected to the identity provider", async ({ page }) => {
+ await page.goto(BASE, { waitUntil: "domcontentloaded" });
+ await expect(page).toHaveURL(/keycloak\.localhost\.pomerium\.io/);
+});
+
+test("allowed user passes the gate and reaches Immich", async ({ page }) => {
+ await login(page, BASE, alice);
+
+ // Immich's own API answers behind the gate. The request rides the same browser
+ // context, so it carries the Pomerium cookie set by the SSO flow. A bare,
+ // dependency-free liveness endpoint that returns {"res":"pong"} -- proving the
+ // request reached Immich itself, not a Pomerium error page.
+ const ping = await page.request.get(`${BASE}/api/server/ping`, { ignoreHTTPSErrors: true });
+ expect(ping.status(), "Immich /api/server/ping should respond 200 behind the gate").toBe(200);
+ const body = await ping.json();
+ expect(body.res, "Immich should answer its liveness ping with pong").toBe("pong");
+
+ // Past the Pomerium gate, Immich serves its own web app (the login / onboarding
+ // screen on a fresh instance). Capture it for the guide.
+ await page.goto(BASE, { waitUntil: "networkidle" });
+ await shot(page, "immich-login");
+});
+
+test("Immich is not reachable except through Pomerium", async ({ page }) => {
+ // Positive control first: Immich IS serving through Pomerium after SSO. This
+ // proves the service is up, so the direct-hit failure below is caused by
+ // network topology, not a dead or typo'd endpoint.
+ await login(page, BASE, alice);
+ const viaPomerium = await page.request.get(`${BASE}/api/server/ping`, {
+ ignoreHTTPSErrors: true,
+ });
+ expect(viaPomerium.ok(), "the route through Pomerium should work").toBeTruthy();
+
+ // The test-runner is not attached to immich-internal, so a direct hit at
+ // immich-server:2283 must fail at name resolution / connection, not return
+ // an HTTP response.
+ let directError = "";
+ try {
+ await page.request.get("http://immich-server:2283/api/server/ping", {
+ ignoreHTTPSErrors: true,
+ timeout: 5000,
+ });
+ } catch (e) {
+ directError = String(e);
+ }
+ expect(
+ directError,
+ "Immich must be unreachable directly; the only path in is through Pomerium",
+ ).toMatch(/ENOTFOUND|getaddrinfo|EAI_AGAIN|ECONNREFUSED/i);
+});
diff --git a/content/examples/guides/immich/validate/compose.validate.yaml b/content/examples/guides/immich/validate/compose.validate.yaml
new file mode 100644
index 000000000..93757e06d
--- /dev/null
+++ b/content/examples/guides/immich/validate/compose.validate.yaml
@@ -0,0 +1,117 @@
+# Sealed E2E validation for the Immich guide. Reuses the shared harness (Keycloak +
+# certs + headless runner) and adds the Immich stack behind a Pomerium wired to the
+# in-network IdP. Run with: scripts/validate-guide-fixtures.sh immich
+#
+# This is a real end-to-end test: it proves Pomerium gates the route, an allowed
+# user passes the gate, and Immich's own API (/api/server/ping) answers behind the
+# gate. The throwaway DB credentials below are for this sealed, ephemeral container.
+#
+# Network isolation mirrors the guide's trust boundary: immich-server, its database,
+# and redis sit ONLY on the internal-only `immich-internal` network, shared with
+# pomerium alone. The test-runner is on `default`, so the only path to Immich is
+# through Pomerium, which bridges both networks.
+#
+# Immich runs its first-boot database migrations against the pinned VectorChord
+# Postgres image, so immich-server gets a generous healthcheck start_period;
+# Pomerium waits for it to report healthy before the browser checks run.
+
+include:
+ - ../../_harness/compose/compose.harness.yaml
+
+services:
+ # Immich requires this VectorChord/pgvecto-rs Postgres image; a stock postgres
+ # image crashes immich-server on first migration because the vector extension is
+ # missing. Pin the exact image Immich ships.
+ database:
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: immich
+ POSTGRES_INITDB_ARGS: '--data-checksums'
+ shm_size: 128mb
+ networks:
+ immich-internal:
+ aliases:
+ - database
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U postgres -d immich || exit 1']
+ interval: 10s
+ timeout: 5s
+ retries: 30
+ start_period: 60s
+
+ redis:
+ image: docker.io/valkey/valkey:9.0.3@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9
+ networks:
+ immich-internal:
+ aliases:
+ - redis
+ healthcheck:
+ test: ['CMD-SHELL', 'redis-cli ping || exit 1']
+ interval: 10s
+ timeout: 5s
+ retries: 30
+ start_period: 10s
+
+ immich-server:
+ image: ghcr.io/immich-app/immich-server:v2.7.5@sha256:c15bff75068effb03f4355997d03dc7e0fc58720c2b54ad6f7f10d1bc57efaa5
+ environment:
+ DB_HOSTNAME: database
+ DB_USERNAME: postgres
+ DB_PASSWORD: postgres
+ DB_DATABASE_NAME: immich
+ REDIS_HOSTNAME: redis
+ depends_on:
+ database:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ networks:
+ immich-internal:
+ aliases:
+ - immich-server
+ healthcheck:
+ # The image ships its own healthcheck; first boot runs DB migrations, so give
+ # it a generous start_period before the browser checks run.
+ test: ['CMD', 'immich-healthcheck']
+ interval: 10s
+ timeout: 10s
+ retries: 40
+ start_period: 240s
+
+ pomerium:
+ image: pomerium/pomerium:v0.32.7@sha256:e10d1d267af24f581157f485d9b0bc08469e2428675b696a08e42ceb09b2279c
+ command: ['--config', '/pomerium/config.yaml']
+ env_file:
+ - ../../_harness/pomerium/validation.env
+ volumes:
+ - certs:/certs:ro
+ - ./routes.validate.yaml:/pomerium/config.yaml:ro
+ depends_on:
+ certs-init:
+ condition: service_completed_successfully
+ keycloak:
+ condition: service_healthy
+ immich-server:
+ condition: service_healthy
+ networks:
+ # On `default` for the IdP + test-runner, and on the internal-only network to
+ # reach Immich. This is the only service bridging the two.
+ default:
+ aliases:
+ - authenticate.localhost.pomerium.io
+ - immich.localhost.pomerium.io
+ immich-internal: {}
+ healthcheck:
+ test: ['CMD', 'pomerium', 'health']
+ interval: 5s
+ timeout: 5s
+ retries: 30
+ start_period: 10s
+
+networks:
+ # Internal-only: no route to the outside, and the test-runner is not attached,
+ # so Immich is reachable only via Pomerium.
+ immich-internal:
+ internal: true
diff --git a/content/examples/guides/immich/validate/routes.validate.yaml b/content/examples/guides/immich/validate/routes.validate.yaml
new file mode 100644
index 000000000..851d6ec98
--- /dev/null
+++ b/content/examples/guides/immich/validate/routes.validate.yaml
@@ -0,0 +1,16 @@
+# Routes-only validation config. Shared settings (IdP, secrets, signing key, certs)
+# come from ../../_harness/pomerium/validation.env via env_file in compose.validate.yaml.
+# Immich runs its own login, so the route is a front-door gate.
+routes:
+ - from: https://immich.localhost.pomerium.io
+ to: http://immich-server:2283
+ allow_websockets: true
+ preserve_host_header: true
+ # Photo and video uploads (and original-quality downloads) can be large and
+ # long-running, so don't cap total request time at the proxy.
+ timeout: 0s
+ idle_timeout: 600s
+ policy:
+ - allow:
+ or:
+ - authenticated_user: true