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
- Format the drive as APFS. Postgres needs real Unix permissions, and exFAT, FAT and NTFS cause trouble.
- Keep it awake. Disable sleep when the display is off, turn off “put hard disks to sleep”, enable restart after a power failure.
- Give the Mac a fixed address with a DHCP reservation in your router, so the phone always finds it.
- RAM: Immich wants about 8 GB. Give the Docker VM enough.
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
- OrbStack only starts after someone logs in. Hands-off recovery needs auto-login, which needs FileVault off. That is a real trade-off, not a free setting.
- Immich keeps hidden marker files in each storage folder and refuses to start if they are missing, so it won't quietly write your photos to the internal SSD when the drive hasn't mounted. If it crash-loops after a reboot, check the drive first.
- Unclean power loss is the classic way to corrupt Postgres. A UPS is cheap insurance.
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.