Home Lab Part 2: Immich — Self-Hosted Google Photos
With the foundation in place — Proxmox, Cloudflare Tunnel, Traefik — it’s time to run the first real service. Immich is a self-hosted alternative to Google Photos with ML-powered search, face recognition, and a mobile app that feels surprisingly close to the real thing.
Why Immich
I wanted to stop paying for cloud photo storage and stop depending on Google or Amazon to host my personal photos. Immich checks all the boxes: automatic mobile backup, face recognition, location-based search, and a clean web UI.
Architecture
Immich runs as four containers:
| Container | Purpose |
|---|---|
immich_server | Main API + web UI (port 2283) |
immich_machine_learning | CLIP and face recognition models |
immich_postgres | PostgreSQL with pgvecto.rs for vector search |
immich_redis | Redis (via Valkey) for job queuing |
Two Docker networks keep things clean:
proxy— Traefik-accessible (only the server container needs this)internal— inter-service communication, isolated from outside
Storage
Photos land on the ZFS HDD mirror for bulk storage, while the database sits on the SSD pool for fast queries:
/mnt/immich/ (HDD ZFS mirror, mounted from host)
├── library/ ← Immich-managed photos
│ └── YYYY/MM/file.jpg ← Storage template: {{y}}/{{MM}}/{{filename}}
├── upload/ ← Raw upload staging
├── thumbs/ ← Regeneratable
├── encoded-video/ ← Regeneratable
└── backups/ ← Daily automated postgres dumps
/opt/services/immich/postgres (SSD, fast I/O)
The storage template {{y}}/{{MM}}/{{filename}} organizes photos by year and month on disk — useful if you ever need to browse the raw files.
Docker Compose
The key parts of the compose file:
services:
immich_server:
image: ghcr.io/immich-app/immich-server:release
volumes:
- /mnt/immich:/usr/src/app/upload
networks:
- proxy
- internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.immich.rule=Host(`immich.johannes-kling.de`)"
- "traefik.http.services.immich.loadbalancer.server.port=2283"
immich_machine_learning:
image: ghcr.io/immich-app/immich-machine-learning:release
volumes:
- model-cache:/cache
networks:
- proxy # needs internet on first run for model download
- internal
immich_postgres:
image: tensorchord/pgvecto-rs:pg16-v0.2.0
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
- internal
immich_redis:
image: valkey/valkey
networks:
- internal
Gotchas
Never touch library/ manually. Immich manages all file placement. Upload only via the mobile app, web UI, or Immich CLI. If you move files around, Immich loses track of them.
ML container needs internet on first run. It downloads CLIP models (~700 MB) from HuggingFace. If it’s only on the internal network, the download fails silently. Put it on proxy (which has internet access via Traefik/Cloudflare) at least for the initial startup.
The .immich marker file. Immich creates a .immich file in the library directory. If it’s missing, Immich refuses to start. Don’t delete it.
Cloudflare blocks uploads >100 MB. The free Cloudflare plan limits upload size. For bulk uploads or large videos, bypass the tunnel and use the LAN address directly: http://192.168.178.10:2283.
Backup
The database gets daily automated dumps into /mnt/immich/backups/. For a manual backup:
docker exec immich_postgres pg_dumpall -U immich \
| gzip > /mnt/immich/backups/manual-$(date +%F).sql.gz
Since photos and database are both on ZFS, a snapshot gives you an atomic, consistent backup of everything.
Critical files to back up off-machine:
/opt/services/immich/.env(contains DB password)- Postgres dumps from
/mnt/immich/backups/
Result
Immich is live at immich.johannes-kling.de. The mobile app syncs photos automatically, face recognition works well, and search by content (“beach”, “dog”) is surprisingly accurate thanks to CLIP. All running on two old SAS drives from eBay.
Next up: OpenCloud — replacing Google Drive and Nextcloud.