gen3ric-labs
← AI Lab Work

Home & lab · Aug–Sep 2026 · with Claude Ongoing A−

A Mac mini as a home photo server

Self-hosting a photo library on a Mac mini with Immich: how I split the storage, reached it from outside without opening a port, backed it up, and found out why the drive was crawling.

The idea

I wanted my phone photos backed up to a machine I own, with a proper timeline, search and face recognition, instead of renting another cloud. Immich is an open-source photo server that does exactly that. The Mac mini was already on, so it became the server, with a USB drive holding the library.

The layout

iPhone (Immich app)
   |  home Wi-Fi, or Tailscale when away
   v
Mac mini -- OrbStack (Docker) -- Immich containers
   |                                  |
   |  internal SSD                    |  USB drive
   |  compose file, .env,             |  photos, videos, thumbnails,
   |  Postgres database               |  nightly database dumps

The split matters. The photos are the bulk, hundreds of gigabytes, and a base-storage Mac mini is too small for them, so they live on the USB drive. The Postgres database is small but latency-sensitive: on a spinning drive over USB the timeline and search feel sluggish, so it stays on the internal SSD. Immich dumps the database nightly into the library folder, so it still gets backed up to the drive.

Before you start

Install

I used OrbStack for Docker. It is lighter on RAM and battery than Docker Desktop and free for personal use. Then, from a folder on the internal SSD, fetch the official compose and env files from the latest release, not from main:

brew install --cask orbstack
mkdir -p ~/immich-app && cd ~/immich-app
curl -L -o docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
curl -L -o .env https://github.com/immich-app/immich/releases/latest/download/example.env

Leave the compose file alone. All the settings live in .env:

# photos, videos, thumbnails, DB dumps -> the USB drive
UPLOAD_LOCATION=/Volumes/<DRIVE>/immich
# Postgres -> the internal SSD
DB_DATA_LOCATION=./postgres
TZ=America/Los_Angeles
# pin a version so you upgrade when you choose to
IMMICH_VERSION=v3.1.0
# letters and numbers only; generate with: openssl rand -hex 24
DB_PASSWORD=<long-random-alphanumeric-string>

Pinning a version means upgrades happen when you decide, after reading the release notes. Then docker compose up -d, open port 2283, and create the admin account. Worth switching on in settings: the daily database dump, and a storage template so the files on disk are human-browsable even if you ever leave Immich.

Getting photos in

Install the iPhone app and point it at the server. For the existing library, a bulk import is much faster than the phone re-downloading originals from iCloud: export from Photos with osxphotos, or take a Google Takeout, then upload with immich-go. Do a --dry-run first. Expect the machine-learning jobs (faces, smart search) to chew for hours or a day or two on a big library.

Away from home, without opening a port

Don't port-forward the server to the internet. Put Tailscale on the Mac and the phone, and in the Immich app set one address for home Wi-Fi and the Tailscale address for everywhere else. Backups work anywhere with nothing exposed.

Surviving reboots and power cuts

One USB drive is not a backup

The library lives on a single drive, so a second one gets a mirror. A launchd job runs weekly at 3 a.m. Sunday with rsync, and it is deliberately paranoid:

SRC=/Volumes/PRIMARY
DST=/Volumes/BACKUP
MARKER="$DST/.backup-target"

# both must be real mounts: an empty mount-point directory plus --delete
# would wipe the backup
mount | grep -q " on $SRC (" || exit 0
mount | grep -q " on $DST (" || exit 0
[ -f "$MARKER" ] || exit 1            # never write to an unverified disk
[ "$(df -k "$SRC" | awk 'NR==2{print $3}')" -gt 104857600 ] || exit 1   # refuse a near-empty source

rsync -aH --delete --backup --backup-dir="$DST/.deleted/$(date +%F)" \
  --exclude=/.deleted/ --exclude=/.backup-target \
  --exclude='.DS_Store' --stats "$SRC/" "$DST/"

# keep deleted/changed files for 60 days, then prune

Changed and deleted files are moved into a dated folder on the backup rather than destroyed, so an accidental delete is recoverable for two months. The checks exist because the failure to fear is a mirror job that faithfully copies an empty disk over a good one. Ideally there is a third, offsite copy too (Backblaze B2 via restic, or keep iCloud Photos on as a parallel copy). Never back up the live postgres folder; rely on the dump files.

The enclosure also has its own idle timer and spins the drives down. A tiny launchd job that touches a hidden file on each drive every five minutes keeps them awake:

for VOL in /Volumes/PRIMARY /Volumes/BACKUP; do
  [ -d "$VOL" ] || continue
  mkdir -p "$VOL/.keepawake"
  date '+%F %T' >> "$VOL/.keepawake/heartbeat.log"
done

The slow-drive mystery

The dock was running at USB 2.0 speed, around 18 MB/s, even though it is rated for 5 Gbps. The cause was a cable that wasn't a SuperSpeed one. After swapping it, the link ran at the full 5 Gbps. If a fast drive feels slow, check the cable before anything else.

Where it stands

It runs, and it is still being tuned, which is why the log marks it ongoing. The most important line in the guide I wrote for myself is the one above: one drive is not a backup.

This is entry 14 of AI Lab Work.