Personal project · 2026 — Present
Immich — self-hosted image server
Private family photo platform on always-on home hardware
- Docker
- Immich
- cloudflared
- Cloudflare DNS
- Tailscale
- macOS
The problem
Family photos were spread across Google Photos and iCloud, with storage costs rising and no real control over where any of it lived. I wanted the convenience of a hosted gallery — apps, sharing, search — without handing the whole archive to a subscription I do not control.
The approach
Run Immich in Docker on a Mac mini that is already on at home, and expose it properly rather than by poking holes in the router. A Cloudflare Tunnel gives it real HTTPS on a public hostname with no inbound ports open, and Tailscale keeps the admin surface on a private network where only I can reach it.
The outcome
A production-grade self-hosted alternative to Google and Apple Photos, running on hardware I own, reachable at gallery.ryankwan.dev. It is also the part of my work that is furthest from the day job — the networking, DNS and container operations that make a frontend engineer better at reasoning about what happens before the browser gets involved.
Architecture moves
- Immich running under Docker on an always-on Mac mini.
- Public HTTPS via a Cloudflare Tunnel (cloudflared), so there are no inbound ports open on the home network.
- Cloudflare DNS with a vanity redirect at gallery.ryankwan.dev.
- Tailscale for private administrative access only — the admin surface is never exposed publicly.
Performance levers
- Runs on hardware that was already powered on, so the marginal energy cost is close to nothing.
- Cloudflare sits in front of the origin, absorbing TLS termination and edge caching.
- Transcoding and thumbnail generation are left to Immich's own background workers rather than being triggered on request.
Engineering impact
- Replaced two paid consumer photo subscriptions with infrastructure I control.
- Gave me hands-on operational experience with containers, tunnels, DNS and private networking — skills that sit outside a frontend remit but make me better at debugging across the boundary.
- Doubles as a live demo of something I actually run, rather than a screenshot.
Challenges conquered
- Exposing a home service to the internet safely, which ruled out port forwarding and led to the tunnel-plus-Tailscale split between public and admin access.
- Keeping a consumer-grade machine reliable enough that family members treat it as a real service and not a hobby project.
Why not just forward a port
The obvious way to reach a service running at home is to forward a port on the router and point DNS at your residential IP. It works, and it is a bad idea. It puts an origin server directly on the public internet with your home network behind it, it breaks whenever the ISP rotates the IP, and it means every scanner on the internet gets to try the login page.
A Cloudflare Tunnel inverts the direction. cloudflared runs on the Mac mini and makes an outbound connection to Cloudflare; Cloudflare accepts public traffic and passes it back down that connection. There are no inbound ports open on the home network at all, TLS terminates at the edge, and the origin's address is never public. The tunnel reconnects on its own when the network drops, which on domestic broadband is not a hypothetical.
Public gallery, private admin
Not everything should be reachable from the same place. Family members need the gallery, and nobody needs the administrative surface — user management, library settings, the machine itself.
So the two are split. The gallery is public over the tunnel at gallery.ryankwan.dev. Administration happens over Tailscale, on a private network where the only members are my own devices. It is the same reasoning as keeping a staging environment off the public internet: if a surface has no reason to be reachable, do not make it reachable, because an unauthenticated attacker cannot exploit a route they cannot open.
What this has to do with frontend work
Very little, directly, and that is rather the point. Most of the hard bugs I have chased in client work happened at a boundary — a caching header, a redirect chain, a TLS mismatch, an environment variable that existed in one place and not another. Running my own origin, DNS and tunnel means those things stopped being someone else's abstraction and became things I have actually configured, broken and fixed.
It also means the demo link on this site points at something I genuinely operate, with the uptime obligations that come with a family that expects to see their photos.