Installing Immich is not hard. What is hard is having a copy you can genuinely return to six months later, when a failure takes the disk with it. I covered the installation side in installing Immich in four steps and the 3-2-1 rehearsal in a separate article. The focus here is different: where the data physically lives, and how that layout dictates your backup plan.
Let me put the most expensive sentence of this article up front: in Immich there is no recovery path called "roll back to the previous version." In the words of the official upgrade documentation, downgrading to an earlier version is not supported, not even within the same minor version. Your rollback plan has to be a database dump, not an image tag.
Do not stray from the official stack
Most Immich examples on the internet show a plain Postgres image like postgres:15-alpine. That is the single most common way to break the installation on day one: Immich expects a purpose-built Postgres image carrying the vector extensions used for search and face recognition. In the official docker-compose.yml, the database line reads:
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
The stack is four services, and three of them publish no ports at all:
| Service | Image | Note |
|---|---|---|
immich-server |
ghcr.io/immich-app/immich-server |
Web UI and API in one service; the only published port is 2283
|
immich-machine-learning |
ghcr.io/immich-app/immich-machine-learning |
Model cache lives in the model-cache volume |
redis |
docker.io/valkey/valkey:9 |
Queue and cache layer |
database |
ghcr.io/immich-app/postgres:14-vectorchord... |
Postgres with vector extensions |
In the official file the redis and database images are pinned by sha256 digest, while the server and machine-learning images follow the IMMICH_VERSION tag.
Do not go looking for a separate immich-web container; the web UI is served from inside the server service. The immich-web service still circulating in older blog posts and stale compose copies does not exist in today's stack.
Installation is built around downloading the published release rather than hand-writing the files:
mkdir immich && cd immich
wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
My advice is to never touch docker-compose.yml. Everything you need to configure lives in .env:
UPLOAD_LOCATION=/srv/immich/data
DB_DATA_LOCATION=/srv/immich/postgres
IMMICH_VERSION=v3
DB_PASSWORD=change-this
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
For DB_PASSWORD the official recommendation is to use only A-Za-z0-9 characters — special characters cause trouble in the connection string. Leaving IMMICH_VERSION on a major tag like v3 is the way to stay current without silently pulling breaking changes.
docker compose up -d
One note: there is no version: "3.8" line at the top of the compose file, and there should not be. The Compose Specification treats that property as informative only and emits an obsolete warning if you use it. What sits at the top instead is name: immich, which pins the project name so container and volume names stay predictable.
Knowing where the data lives
Immich has two persistent paths, and they behave very differently.
UPLOAD_LOCATION is mounted into the container as /data. Which folder underneath holds what depends on the storage template setting, and this is where most write-ups get it wrong:
- With the storage template off (the default on new installations), originals uploaded from browser, mobile and CLI live under
upload/<userID>. Thelibrary/folder is not used in that case. - With the storage template on, the engine moves all assets under
library/<userID>, andupload/becomes the temporary queue for mobile uploads, with files moving tolibrary/once the upload completes.
The official documentation recommends backing up the entire contents of UPLOAD_LOCATION; the critical — irreplaceable — content sits in three folders: library, upload and profile. Everything else is regenerable:
| Folder | Content | If lost |
|---|---|---|
library |
Originals when the template is on | Irreplaceable |
upload |
Originals when the template is off; mobile queue when on | Irreplaceable |
profile |
User profile images | Irreplaceable |
thumbs |
Preview and face thumbnails | Regenerable |
encoded-video |
Videos re-encoded for compatibility | Regenerable |
backups |
Immich's own automatic database dumps | Regenerable |
The documentation treats backing up only the first three as a legitimate choice; the price is that you must rerun transcoding and thumbnail generation for every asset after a restore. Make that trade between disk cost and recovery time deliberately. I back up the first three strictly; thumbs and encoded-video go in if there is room, and stay out if there is not.
DB_DATA_LOCATION is an entirely different thing. The comment in the official .env is explicit: network shares are not supported for the database. A Postgres directory you place on NFS or SMB comes back one day as a lock-up or a corruption. That path belongs on local disk.
Three quiet details in the compose file
There are three lines in the official compose file you probably skim past, and all three are directly relevant to this article.
The first is POSTGRES_INITDB_ARGS: '--data-checksums' on the database service. As PostgreSQL's documentation describes it, this option keeps checksums on data pages to help detect corruption from the I/O system that would otherwise be silent, and every checksum failure is reported in the pg_stat_database view. The documentation notes it might incur a small performance penalty. For a photo archive that sits untouched for years, that trade is easy: noticing silent corruption late costs far more.
The second is shm_size: 128mb. Postgres takes the dynamic shared memory used by parallel query workers from /dev/shm, and Docker's default 64 MB gets tight on larger queries. You notice this line exists as your library grows.
The third is restart: always together with the healthcheck definitions. Do not conflate them: a restart policy only fires when the container exits, and it does not look at health status. A crashed service comes back, but a service that wedges while still running will not fix itself. If you want unhealthy containers restarted automatically you need a separate component for that; compose does not do it.
What is actually exposed?
The only published port in the official compose is 2283, and only on immich-server. The database and Valkey services have no published ports at all; they are reachable only from inside the compose network. Do not break that default — the "add 5432:5432 so you can debug it" advice you find online exposes your database directly.
If you want to use Immich away from home, the right path is not forwarding 2283 through your router, but putting a reverse proxy in front of it and terminating TLS there. A photo archive is by definition your most intimate dataset; it is not something to carry over plaintext HTTP.
There is also this: your .env file contains DB_PASSWORD. Do not put that file inside the backup — but do not lose it either. I keep .env as a separate entry in a password manager; an installation that has the dump but cannot remember the database password is the last surprise you want during a disaster.
Backups: dump the database, do not copy the directory
The official documentation gives pg_dump for command-line database backups — alongside Immich's own automatic dumps, which I come to shortly. My own advice is not to copy the Postgres data directory at the filesystem level, and the reason is this — a running Postgres data directory can be in an inconsistent state at any moment, so what you copied may not come up when restored, and you find that out during the disaster. Treating a directory copy as a backup means buying a hope whose consistency was never tested.
The correct command is:
docker exec -t immich_postgres \
pg_dump --clean --if-exists --dbname=immich --username=postgres \
| gzip > /srv/backup/immich-dump.sql.gz
Use both flags deliberately. --clean puts DROP statements into the dump before the create statements, which is what makes it restorable over an existing database. --if-exists turns those into DROP ... IF EXISTS and suppresses "does not exist" errors — and as the PostgreSQL documentation stresses, that option is not valid unless --clean is also specified.
There is a scope caveat as well: pg_dump dumps a single database only. Cluster-wide objects such as roles and tablespaces need pg_dumpall. Immich runs on one database so pg_dump is enough here, but it is worth remembering if you host other workloads on the same Postgres.
Ordering is not left to chance
Because the files and the database are backed up separately, the two can drift apart. The official documentation's "backup ordering" section gives a clear hierarchy.
The best option is to stop the immich-server container while the backup runs. If nothing is changing, the backup is always in sync.
If stopping the container is not an option, back up the database first and the filesystem second. In that order the worst case is files on the filesystem that the database does not know about, and those can be re-uploaded manually after a restore. In the reverse order — filesystem first, database second — the restored database can reference assets that are missing from the file backup, and the result is broken assets. The two directions are not symmetric, which is why the order is a rule rather than a preference.
The repository has to be created once with restic init before first use; otherwise the script dies on its first run with "repository does not exist".
#!/usr/bin/env bash
# /usr/local/bin/immich-backup.sh
set -euo pipefail
STAMP="$(date +%F)"
BACKUP_DIR="/srv/backup/immich"
DATA="/srv/immich/data"
export RESTIC_REPOSITORY="sftp:backup@nas:/srv/restic/immich"
export RESTIC_PASSWORD_FILE="/etc/restic/immich.pass"
mkdir -p "$BACKUP_DIR"
# 1) Database dump FIRST
docker exec -t immich_postgres \
pg_dump --clean --if-exists --dbname=immich --username=postgres \
| gzip > "$BACKUP_DIR/dump-$STAMP.sql.gz"
# 2) Verify the dump — never let a truncated file into the backup
gzip -t "$BACKUP_DIR/dump-$STAMP.sql.gz"
[ "$(stat -c%s "$BACKUP_DIR/dump-$STAMP.sql.gz")" -gt 100000 ]
# 3) Files SECOND: the three critical folders plus the fresh dump
restic backup \
"$DATA/library" "$DATA/upload" "$DATA/profile" \
"$BACKUP_DIR/dump-$STAMP.sql.gz" \
--tag immich
# 4) Retention — only snapshots belonging to this job
restic forget --tag immich \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
# 5) Do not let local dumps pile up
find "$BACKUP_DIR" -name "dump-*.sql.gz" -mtime +7 -delete
The set -euo pipefail line is not decoration: without it the script carries on even when pg_dump fails and cheerfully places a near-zero-byte .sql.gz into your backup. Step 2 exists for the same reason — gzip -t tests archive integrity without unpacking it, and the size check catches the "valid but empty" dump. Silently producing a broken backup is more dangerous than having none, because you are buying a false sense of safety.
One small trap: the -t flag on docker exec allocates a pseudo-TTY, which can turn the line endings in the output into CRLF. The command appears in this form in the official documentation and causes no trouble in most setups, but if a restore throws odd errors, try it without -t — gzip -t will not catch this one.
Is Immich's own backup enough?
Immich keeps its own automatic database dumps under UPLOAD_LOCATION/backups; the schedule and retention count are managed in Administration > Settings > Backup (default: daily at 2:00 AM, keep the last 14). To take one by hand there is Administration > Job Queues > Create job > Create Database Dump.
But the documentation's own warning is the critical part here: these dumps contain no photos or videos, only metadata. On top of that they sit on the same disk as the files. If the disk goes, both go.
Think of that folder as "fast undo" rather than backup — excellent for recovering a database you broke the same day, worthless against a disk failure. Real protection means the dump and the original files live physically on another machine. I covered the copy-count and location side of that in the 3-2-1 backup rehearsal.
Restoring: the interface first, the command line second
A backup is not a backup until it has been restored. Immich offers two restore paths, and the documentation marks the web interface as the recommended method for most users.
On an existing installation the route is: open Administration > Maintenance, expand the Restore database backup section, pick a dump from the list and click Restore. The interface automatically creates a restore point before the operation begins and rolls back to it if the restore fails. On a fresh installation there is the Restore from backup flow on the welcome screen: you move the old UPLOAD_LOCATION folders into the new installation first, Immich enters maintenance mode and shows an integrity check for those folders, and then you pick the dump.
The interface also flags version compatibility: if the dump was created on a different version than the one running, you get a warning. The documentation is explicit that restoring across versions may require database migrations. Note also that the backup and restore process changed in v2.5.0; if you hold a dump taken with an older Immich, use the documentation's version selector to find the instructions matching it.
The command-line path is for advanced scenarios. The sequence below is from the official documentation; do not run it without understanding every line:
docker compose down -v # CAUTION! Removes named volumes
# To permanently reset Postgres (REQUIRED on an existing installation):
rm -rf /srv/immich/postgres # CAUTION! Deletes the database entirely
docker compose pull
docker compose create
docker start immich_postgres
sleep 10
gunzip --stdout /srv/backup/immich/dump-2026-09-02.sql.gz \
| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
| docker exec -i immich_postgres \
psql --dbname=immich --username=postgres \
--single-transaction --set ON_ERROR_STOP=on
docker compose up -d
Three details make this sequence work. First, the sed line rewrites the search_path setting inside the dump; skip it and the restore does not land as expected. Second, per the documentation's own note, a restore requires a completely fresh install — if the Immich server has ever run you will hit "relation already exists" and foreign key violations, which is why deleting DB_DATA_LOCATION is a real step rather than a comment. Third, --single-transaction together with ON_ERROR_STOP=on applies the dump as one transaction and halts on the first error, so a "half-loaded database" cannot happen.
In some deployments it is hard to start the database without also starting the server; for that case the documentation recommends setting DB_SKIP_MIGRATIONS=true before starting the services, which stops the server from running migrations that interfere with the restore. Remove the variable and restart the services once the restore is done.
Do not run this rehearsal on your live installation. The rm -rf line in that sequence permanently deletes your database. (down -v only removes named volumes; UPLOAD_LOCATION and DB_DATA_LOCATION are bind mounts, so it does not touch them — rm -rf does the actual deleting.) For a rehearsal, bring up a second installation in a separate directory with its own .env and restore the dump there. I do this quarterly, and every single time something surprises me — usually not a technical surprise but a human one, like not remembering where the password file was.
Upgrading — and not being able to go back
Immich ships often. The upgrade command is simple:
docker compose pull && docker compose up -d
What matters is what comes after. The official upgrade documentation states that downgrading to an earlier version is not supported, not even within the same minor version, and that patches are not backported to older releases. There is also a specific warning not to go below 1.133.0 after switching to VectorChord. Database migrations are one-way.
The practical consequence: keeping an old image around with docker tag gives you no recovery path. Even if you roll the image back, the schema has already migrated, and the older server cannot read it. So the pre-upgrade ritual is exactly one thing — take the dump:
docker exec -t immich_postgres \
pg_dump --clean --if-exists --dbname=immich --username=postgres \
| gzip > /srv/backup/immich/pre-upgrade-$(date +%F).sql.gz
docker compose pull && docker compose up -d
If the upgrade goes badly, the path back is not rolling the image back. Putting an older release on top of a migrated database is precisely the downgrade the documentation does not support. The documentation offers no ready recipe for this scenario; the order I follow is to set IMMICH_VERSION in .env to the older release and restore that version's dump with the command-line sequence above — that is, into a fresh database with DB_DATA_LOCATION deleted. Rollback is a restore operation, not an image operation, and it is only possible if you hold a dump from that version.
One more caveat: which image can read a given dump matters. A backup taken after the move to VectorChord needs an image containing VectorChord to restore. If you keep dumps for months, record which Immich version produced each one. That also puts a floor under your rollback target: after moving to VectorChord you cannot go below 1.133.0.
Checklist
Verify these one by one on your own installation:
- Are all four services healthy in
docker compose ps? (immich_server,immich_machine_learning,immich_redis,immich_postgres) - Is
DB_DATA_LOCATIONon local disk? If it is on a network share, move it. - Does your backup script use
pg_dump, or is it copying the Postgres directory? - Is the order right — is the container stopped, and if not, is the database taken before the files?
- Is the dump's integrity tested after it is written? (
gzip -tplus a minimum size) - When did you last restore your most recent dump? If you cannot name a date, your backup is unverified.
- Do the backups physically live on another machine? A copy on the same disk is not a backup against disk failure.
- Is your
.env, and theDB_PASSWORDin it, stored safely outside the backup?
Closing
Installing Immich takes an afternoon. What makes it dependable is three decisions: choosing deliberately which path the data sits on, taking the dump and the files in the right order, and resting your recovery on a dump rather than an image tag.
A photo archive is a strange kind of data — you usually do not notice the loss on the day it happens. So the measure is not "am I taking backups?" but "when did I last restore one?"
Top comments (0)