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-proxy and 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

Immich behind Cloudflare Tunnel and Access Three kinds of client reach photos.matheja.me. Share links go through their own Cloudflare Access application, which lets everyone through, to the public proxy. The Immich app, with a service token in its headers, and browsers, with an allowlisted email address, go through the Access application for the rest of the hostname. Both paths run through one Cloudflare Tunnel to Traefik in the homelab, which sends /share to immich-public-proxy and everything else to immich-server. Immich logs users in with OIDC against Authentik, which sits behind its own Access application and uses Google as the identity. Clients Share linkanyone, no account Immich appservice token headers Browserallowlisted email Edge Access: /shareits own application,bypass for everyone Cloudflare Accessservice token or home network: bypasseveryone else: email allowlist Tunnel Cloudflare Tunneldials out from the homelab, no open ports Homelab Traefiksplits the hostname by path Apps Public proxy/share only immich-servereverything except /share OIDC Login Googlethe identity users already have Authentikbehind its own Access app Immich behind Cloudflare Tunnel and Access Clients pass Cloudflare Access, then one Cloudflare Tunnel to Traefik, which sends /share to the public proxy and everything else to Immich. Immich logs users in through Authentik and Google. Clients App · Browser · Share linktoken, allowlisted email, or nobody Edge Cloudflare Accesstoken or home IP pass, /share is open Tunnel Cloudflare Tunneldials out, no open ports Homelab Traefik/share to the proxy, the rest to Immich Apps immich-serverlogin: OIDC, Authentik, Google public proxy: renders /share links only
Share links and the app pass different Access rules, then the same tunnel. The login runs from Immich through Authentik to Google.

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

  1. Cloudflare Access on photos.matheja.me keeps 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.
  2. Cloudflare Access on the identity provider keeps strangers away from the Authentik login page. Again an email allowlist.
  3. 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 /share on the external hostname to the proxy, with a higher router priority than the Immich router.
  • /share is 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. email is literal, email_domain is 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. /share as 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.