StackNotes

Immich backup to an external drive, properly

6 November 2026 · Nysa Dimitriou · 9 min read

The short version: the photos are the easy half. Dump Postgres, back up the upload folder to a disk that is not your array, and restore both halves in a drill. A folder of JPEGs on its own is not an Immich backup.

Your Immich library is probably the largest thing you own that you cannot download again. It is also the one where a file-level copy feels like it should be enough, because the files are just photos. They are. The library is not.

Immich keeps the meaning of those photos in Postgres: which asset belongs to which album, who is in the frame, what you starred, what you shared, the order of a stack, the date the camera recorded rather than the date the file was copied. Lose the database and you still have every original. You have lost the library.

I tested this the boring way. A 151 MiB Immich-shaped library — 503 files, 200 thumbnails, one 80 MiB transcode, a Postgres dump with 20,000 asset rows — backed up onto a 4 GiB ext4 volume through restic 0.18.0, then restored. Here is the procedure, the numbers from that run, and the failure I induced on purpose.

What is actually on the disk

Here is everything Immich writes, and which parts you cannot get back:

PathWhat it holdsRegenerable?
upload/librarythe originals, in dated foldersno — this is the data
upload/thumbs200 WebP thumbnails, 12.3 MB hereyes, by a job
upload/encoded-videotranscodes, one 83,886,080-byte file hereyes, by a job
upload/profileuser avatarsno
the Postgres volumealbums, people, faces, EXIF, sharesno
.envthe database password and pathsno

In my test set the upload folder was 145,538,174 bytes and the thumbnails were 12,290,980. One video transcode was 83,886,080 bytes of that upload figure, which tells you where the space goes in a real library: derivatives, not stills. Scale that up: at roughly 60 KB per thumbnail in my set, 200,000 photos is about 12 GB of thumbnails (arithmetic, not a measurement), plus however many hundreds of GB of transcoded video came from files you already have.

Which is the first decision, and it is a real one. You can back up the derivatives and pay for the space, or exclude them and pay in CPU time when you restore. I exclude transcodes always. I keep thumbnails, because regenerating them makes the library usable again in minutes instead of overnight, and in my set they were 8% of the bytes.

The database is not a folder to copy

Every guide that says “copy the Immich folder” is one bad restore away from teaching you why that is wrong. Postgres on a live server is a collection of files being written underneath you, exactly like SQLite in WAL mode. A cp -a of the data directory captures whatever was in flight. Sometimes it opens. The times it does not, it opens with a torn page and a recovery prompt you should not be answering at 2 a.m.

Use the dump. Immich’s own backup documentation uses pg_dumpall inside the Postgres container:

docker exec -t immich_postgres pg_dumpall --clean --if-exists \
  --username=postgres > /mnt/user/dumps/immich/immich.sql

--clean --if-exists makes the dump self-dropping, which matters because a restore into a database that already has tables is how you get a half-old, half-new schema. Run it before the file backup, never during. Immich writes constantly while it is indexing, and a dump is cheap: my 20,000-row table produced a 627,714-byte custom-format dump that pg_restore put back in 169 ms.

One thing the dump does not carry: the extension. Immich needs its vector extension present before you restore a schema that references it. Install the identical Immich version, let it create its database and extension once, then restore over the top. If you are moving between Immich majors, read the release notes for the database before you touch the dump, because the extension changed and a dump from the old one will not load into the new image.

Mount the disk so a missing disk cannot pass

The most expensive backup mistake in a homelab is not corruption. It is success. The USB disk unmounts after a power cut, /mnt/extdisk remains as an empty directory on the array, the backup runs, restic exits 0, and you spend eight months backing up 600 GB onto the array you were trying to protect it from.

Mount by UUID, not by device name, and do not let the mount block boot:

# /etc/fstab
UUID=6f1c2d3a-... /mnt/extdisk ext4 noauto,nofail,x-systemd.automount 0 2

Then refuse to run when it is not there:

mountpoint -q /mnt/extdisk || { echo "extdisk not mounted"; exit 1; }
findmnt -no FSTYPE /mnt/extdisk | grep -qx ext4 || exit 1

Both lines belong in the wrapper, not in your memory. The first one is the whole point.

Format it ext4. exFAT and NTFS will store the bytes and drop the ownership and mode bits, and Immich’s container runs as a specific uid that will not be able to read a tree restored as root-owned 777. If you need the disk readable on Windows too, buy a second disk for that, not this one.

The backup itself

restic -r /mnt/extdisk/immich-restic backup \
  /mnt/user/appdata/immich/upload \
  /mnt/user/dumps/immich \
  --exclude thumbs --exclude encoded-video \
  --tag immich

The dump goes in the same snapshot as the files, so the two halves always match. That is the design decision that makes the restore drill meaningful: a snapshot from Tuesday contains Tuesday’s database and Tuesday’s originals, not Tuesday’s database and Wednesday’s files.

What the three runs cost on this machine:

RunContentSnapshot sizeAdded to repoTime
initnew repository on ext4——2,999 ms
first backupall 503 files150.508 MiB150.682 MiB1,276 ms
secondderivatives excluded58.789 MiB3.395 KiB755 ms
third+25 new photos62.059 MiB3.281 MiB771 ms

Read the “added” column of the last row. Twenty-five new photos cost 3.3 MiB and under a second, because restic only writes what changed. A 2 TB library is not 2 TB of nightly writes; after the first run it is however many photos you imported that day.

Compression barely helped: 0.09% saved. My test files were random bytes, which is the worst case, but JPEGs and MP4s are already compressed, so do not expect restic’s compression to shrink a photo library the way it shrinks logs and database dumps. It earns its keep on the dump, not the media.

Pulling the cable

A backup you have never interrupted is a backup you are guessing about. So I pulled the volume out from under restic two seconds into a 1.4 GB write:

Save(<data/34e4462f14>) failed: chmod .../data/34/34e4462f14...: no such file or directory
Fatal: unable to save snapshot: chmod ...: no such file or directory
restic exit code: 1

Exit 1, not a cheerful summary. The snapshot does not exist. restic writes the snapshot file at the end, after the data, so a run that dies takes its own snapshot with it. A half-written pack file is left behind with nothing pointing at it; the next prune removes it.

What it also left was a stale lock, which is the part that confuses people. After remounting I ran restic unlock (it removed one lock) and restic check, which walked all three snapshots and reported no errors. restic stats still counted 571 blobs. The repository was fine. The rule is mechanical: non-zero exit, check the mount, clear the stale lock, run check, and only then decide whether anything is wrong. Do not re-init a repository because one run died. Do not delete it either.

The restore, both halves

Restore to a scratch directory first, so you can look at the files before they touch anything:

restic -r /mnt/extdisk/immich-restic restore latest \
  --target /mnt/user/restore-test

Mine restored 327 files and 62.059 MiB in 914 ms, and the first five checksums I compared matched the source exactly. Quick, because 62 MiB is nothing. At 600 GB, expect the restore to be bounded by the disk: a USB 3 hard drive writing at 120 MB/s takes about 85 minutes for 600 GB, and that is an estimate, not a measurement from this bench.

Then the database, into a fresh Immich database, using the dump from the same snapshot:

docker compose down
docker compose up -d immich_postgres
cat immich.sql | docker exec -i immich_postgres psql --username=postgres --dbname=postgres
docker compose up -d

Only after both halves are in place do you start the server, and then you check three things: the asset count in the UI matches the file count on disk, an album you remember opens with the right photos in it, and a person’s face grouping still resolves. If the count matches but the albums are empty, you restored the files and not the dump.

I did not run Immich itself for this test. I restored the two things Immich depends on — 327 original files with matching checksums, and a 20,000-row Postgres table — and that is the limit of the claim. The step I could not exercise is the vector extension install, and it is the one I would rehearse before trusting any of this with a real library.

What I would not do

rsync –delete as the only copy. It mirrors deletions within the hour and keeps no versions, so the day you delete the wrong album, the backup agrees with you. It also never reads back what it wrote.

Two copies on the same machine. A second folder on the array is a copy, not a backup. The external disk exists to survive the array, and the off-site copy exists to survive the house. If you only ever do one of the three, do the external disk. Two disks in one room is not 3-2-1, it is 3-2-1 with a missing leg.

Backing up the Postgres data directory. Already covered, but it bears repeating because it is the mistake that looks like competence.

A backup with no restore drill. The monthly restore is the product. Everything above is plumbing.

The monthly drill

Run the restore to a scratch path, count files, compare a sample of checksums, then restore the dump into a throwaway database and count the rows. My version of that is restore-verify.sh, and the wrapper that dumps first and backs up second is backup-nightly.sh. Both come from the same bench as the full backup stack, which explains why the dump step exists at all and what happens to a restic repository when a pack file rots.

Two numbers to keep: 327 files restored with identical checksums, and 20,000 rows back in 169 ms. Those are the only two lines that tell you the backup works. Everything else is a log that says it tried.