Self-Hosted Photo Management: Immich Behind Cloudflare Tunnel
Immich has replaced Google Photos for part of my family: each of them backs up their own phone into their own library on a server in my basement. The hard part was not running Immich but letting a mobile app through Cloudflare Access without a browser in front of it. The answer is a Cloudflare service token in the app’s custom headers, followed by a normal OIDC login against Authentik.
The Setup
- Host: a Proxmox VM in my “Platform One” environment, Docker Compose deployed by an Ansible role
- Stack:
immich-server,immich-machine-learning, Postgres (with vector search), Redis,immich-public-proxyand a backup sidecar, all on pinned versions that Renovate bumps - Reverse proxy: Traefik, serving both the internal and the external hostname
- External access: Cloudflare Tunnel, no open ports at home
- Access control: Cloudflare Access in front of the app, Authentik (OIDC) for the actual login, Google as the identity users already have
- Everything as code: Terraform for Cloudflare Access and Authentik, Ansible for the stack
Architecture
Traefik routes both hostnames, the external photos.matheja.me and an internal name with a Let’s Encrypt certificate. A router rule splits each host by path: /share goes to the public proxy, everything else to Immich.
# docker-compose (excerpt, rendered by Ansible)
labels:
- traefik.http.routers.immich-external.rule=Host(`photos.matheja.me`) && !PathPrefix(`/share`)
- traefik.http.services.immich.loadbalancer.server.port=2283
# immich-public-proxy
- traefik.http.routers.immich-public-external.rule=Host(`photos.matheja.me`) && PathPrefix(`/share`)
- traefik.http.routers.immich-public-external.priority=100
Why Cloudflare Tunnel
My connection is DS-Lite, so there is no public IPv4 to forward a port to. The tunnel connects outbound from the homelab, Cloudflare terminates the public hostname, and nothing at home listens on the internet. It also puts Cloudflare Access in front of every hostname for free.
The Problem: An App Is Not a Browser
Cloudflare Access protects a hostname with an interactive login. A browser handles that fine. The Immich mobile app does not: it expects to talk to its server’s API directly, and an Access login page in front of it just looks like a broken server.
Dropping Access for the hostname would expose Immich’s login page to every scanner on the internet. Keeping it would lock out the app.
The Solution: Service Token in Custom Headers
Cloudflare Access supports service tokens: a client id and a secret that a non-browser client sends as headers. An Access policy with the action Service Auth lets requests carrying a valid token through without any interaction.
The Immich app has exactly the hook for this: Settings → Advanced → Custom Headers. Every request the app makes then carries:
| Header | Value |
|---|---|
CF-Access-Client-Id |
the service token’s client id |
CF-Access-Client-Secret |
the service token’s secret |
The token gets the app past Cloudflare. It does not log anyone into Immich. That is the next layer.
Three Layers, Three Jobs
- Cloudflare Access on
photos.matheja.mekeeps bots and scanners away from Immich. The app passes with the service token, the home network passes by IP, and browser users need an allowlisted email address. - Cloudflare Access on the identity provider keeps strangers away from the Authentik login page. Again an email allowlist.
- Authentik decides who may actually use Immich: users sign in with Google, and only members of a dedicated group get a token for the Immich application.
Immich itself is configured for OAuth against Authentik, with the mobile redirect URI app.immich:///oauth-callback registered next to the web one. Each person gets their own library, so nobody sees anybody else’s photos.
The policies are ordered by precedence in Terraform: service token bypass first, then the home IP, then the email allowlist. A module creates the same pattern for every tunnelled application.
The Gotcha: *@domain Matches Nobody
The first family member to try it was blocked, although her address was “on the list”. The list contained *@matheja.me.
Cloudflare Access’s email rule (include { email = [...] } in Terraform) matches addresses literally. *@matheja.me is not a pattern; it is one address that does not exist, so it matched nobody, and the only address that had ever worked was the one listed in full. Whole domains need the separate email domain rule (include { email_domain = [...] }). After adding that to the module, every address on the domain passed.
It is the kind of mistake that fails quietly: no error, no warning, just a login page that never lets you through.
Public Sharing Without Accounts
Sharing an album with someone who has no account is the other half of replacing Google Photos. Immich’s own share links still go through the app’s login flow, so they are not much use to a grandparent with a browser.
immich-public-proxy renders shared links without any login and exposes nothing but the shared content. Two pieces make it work behind the tunnel:
- Traefik sends
/shareon the external hostname to the proxy, with a higher router priority than the Immich router. /shareis its own Cloudflare Access application with a bypass for everyone. The more specific path wins over the application that protects the rest of the hostname.
Share links contain long random keys and can be revoked in Immich at any time.
What Using It Feels Like
Onboarding a family member takes five steps: install the app, enter the server address, paste the two headers, sign in with Google, allow the backup.
The honest part: the very first login asks for Google twice, once at the Cloudflare layer in front of Authentik and once in Authentik itself. After that the Cloudflare session cookie stays on the phone, and later logins only need the Authentik step. I explain this up front, and nobody has been confused by it since.
The app is a backup, not a mirror: deleting a photo on the phone does not delete it in Immich. That turned out to be the most important sentence in the onboarding guide.
One limit is not mine to fix: on iOS, sharing a photo from Immich straight to WhatsApp freezes the share sheet (immich#24614). Sharing a link instead works.
Operations
- Backups: a stack-back sidecar (restic) backs up the volumes on a schedule; the library itself lives on the NAS.
- Monitoring: Uptime Kuma checks the service, set up automatically from container labels.
- Updates: versions are pinned in the Ansible role, Renovate opens the merge requests, a pipeline deploys them.
- Performance: at home the internal hostname goes straight to Traefik. Through the tunnel there is some extra latency, which is fine for browsing; the initial backup of a phone is best done at home.
Lessons Learned
- Service tokens are the bridge between Zero Trust and native apps. If an app lets you set custom headers, it can live behind Cloudflare Access.
- Read how an access rule matches before trusting a wildcard.
emailis literal,email_domainis the domain rule. - Separate “may reach it” from “may use it”. Cloudflare decides who reaches the hostname; Authentik decides who uses the application. Each layer stays simple.
- A path can be more public than its host.
/shareas its own Access application keeps public links open without loosening the rest. - Write the guide for the person, not the architecture. The onboarding page never mentions OIDC, only which button to press and that deleting on the phone is safe.
Conclusion
For those of us who switched, Immich covers what we used Google Photos for, and those photos now live on hardware I own. Cloudflare Tunnel removes the need for open ports, Cloudflare Access with a service token lets the mobile app through without weakening the protection for everyone else, and Authentik keeps the question of who may use it in one place.