Backing up a self-hosted Immich¶
Immich (the self-hosted Google Photos alternative) is worth backing up carefully because it is really two things glued together, and they fail in different ways:
- the media — the actual photo and video files on disk, and
- the database — Postgres, holding the library index, albums, people/faces, and the machine-learning results.
Lose the media and the photos are gone. Lose the database and the photos are still there on disk, but Immich no longer knows what they are — no albums, no dates, no faces, no order. Both are irreplaceable, and a good backup covers both.
About the paths
Paths like /disks/nas4t and /disks/sata_bkup below are from my box — substitute your own primary data disk and backup disk. The shape is the point, not the exact mount points.
The layout: media and database live apart on purpose¶
| what | where | why |
|---|---|---|
media (UPLOAD_LOCATION) | big data disk, e.g. /disks/nas4t/immich/media | photos need room to grow |
live database (DB_DATA_LOCATION) | local OS/SSD disk, a bind mount | Postgres must not run on a network/NAS-style mount — it corrupts |
| DB dumps | next to the media, e.g. /disks/nas4t/immich/db-backups | so one backup pass sweeps up both |
| second copy | a separate backup disk, e.g. /disks/sata_bkup/immich | the actual off-primary-disk backup |
The key decision: the live database sits on a fast local disk (never the NAS), but its nightly dump is written onto the data disk right next to the media. That way a single mirror of one directory tree (immich/) captures the media and the database dump together.
graph LR
live["Live Postgres<br/>(local SSD)"] -- "pg_dumpall (nightly 02:30)" --> dump["/disks/nas4t/immich/db-backups"]
media["/disks/nas4t/immich/media"]
dump -->|"same tree"| tree["/disks/nas4t/immich"]
media --> tree
tree -- "rsync --delete (nightly 03:30)" --> copy["/disks/sata_bkup/immich<br/>(second disk)"] Three physical copies of the irreplaceable bits result: the live DB on the SSD, its dump on the data disk, and the whole tree mirrored to a second disk.
Part 1 — the nightly database dump¶
pg_dumpall runs inside the Postgres container, so no Postgres client is needed on the host. The dump is gzipped and old ones are pruned. --clean --if-exists makes the dump restorable onto a fresh cluster.
#!/usr/bin/env bash
# Nightly Immich Postgres backup: pg_dumpall -> gzip -> data disk, prune > 14 days.
set -euo pipefail
DEST=/disks/nas4t/immich/db-backups
CONTAINER=immich_postgres
DB_USER=postgres
KEEP_DAYS=14
mkdir -p "$DEST"
OUT="$DEST/immich-$(date +%Y%m%d-%H%M%S).sql.gz"
docker exec -t "$CONTAINER" pg_dumpall --clean --if-exists --username="$DB_USER" \
| gzip > "$OUT.tmp"
mv "$OUT.tmp" "$OUT" # atomic: never leave a half-written .sql.gz
find "$DEST" -name 'immich-*.sql.gz' -type f -mtime +"$KEEP_DAYS" -delete
Write to .tmp, then rename
Piping straight into the final filename means an interrupted run leaves a truncated dump that looks like a valid backup. Writing to OUT.tmp and mv-ing on success makes the final file appear only when the dump completed.
Part 2 — the second copy on another disk¶
A dump on the same disk as the live data protects against a bad DELETE, not against the disk dying. So mirror the whole immich/ tree — media and the dumps — onto a second disk. One rsync covers both because the dump lives inside that tree.
#!/usr/bin/env bash
# Mirror the whole Immich tree (media + DB dumps) onto a second disk.
set -euo pipefail
SRC=/disks/nas4t/immich/
DEST=/disks/sata_bkup/immich/
# Refuse to mirror into an unmounted backup disk (would fill the root fs).
mountpoint -q /disks/sata_bkup || { echo "backup disk not mounted" >&2; exit 1; }
# CRITICAL with --delete: refuse if the source looks empty (e.g. NAS not mounted),
# otherwise the mirror would happily DELETE the good backup.
[[ -d "$SRC" && -n "$(ls -A "$SRC" 2>/dev/null)" ]] || { echo "source empty/missing" >&2; exit 1; }
mkdir -p "$DEST"
rsync -ah --delete --partial --info=progress2 "$SRC" "$DEST"
--delete needs a guard
A mirror (--delete) tracks the source exactly, including deletions — which is what you want, until the source disappears. If the NAS fails to mount, SRC is an empty directory, and rsync --delete will erase the entire backup to "match" it. The two guards above (backup mounted, source non-empty) turn that disaster into a clean refusal. This is the single most important line in the whole setup.
This job must run after the dump, so it picks up the freshest one — schedule it an hour later (see below).
Scheduling with user systemd timers¶
Both scripts run as a user systemd timer (not root cron), so they run as the same user that owns the Docker socket and the files.
# ~/.config/systemd/user/immich-db-backup.timer -> 02:30
# ~/.config/systemd/user/immich-backup-copy.timer -> 03:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true # if the box was off at 02:30, run at next boot
[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now immich-db-backup.timer immich-backup-copy.timer
systemctl --user list-timers 'immich*'
Enable lingering, or the timers die at logout
User timers only run while the user has a session — they stop when you log out of SSH. sudo loginctl enable-linger $USER keeps the user manager (and its timers) running with no login. Easy to forget; the symptom is "the backup ran while I was testing, then silently never again."
Restoring¶
Database — restore the latest dump onto a running (fresh) Postgres container; the --clean --if-exists in the dump drops and recreates as it goes:
LATEST=$(ls -t /disks/nas4t/immich/db-backups/*.sql.gz | head -1)
zcat "$LATEST" | docker exec -i immich_postgres psql -U postgres -d postgres
docker restart immich_server
Media — it is just files, so copy it back into place:
Restore order for a full rebuild: bring up the stack with an empty DB, restore the dump before the first real use, then restore the media.
What I learned¶
- Back up both halves, and know what each protects. The media is the files; the database is the meaning. A media-only backup gives you a shoebox of unsorted photos.
- The live DB belongs on local disk; only its dump goes to the NAS. Running Postgres directly on a network mount is a corruption trap.
- Put the dump inside the media tree so a single mirror backs up everything.
- A
--deletemirror is a loaded gun without a source-not-empty guard — an unmounted source turns "sync" into "wipe the backup." - User timers need lingering enabled or they quietly stop after you log out.