Back up and restore Immich on a VPS
What an Immich backup must contain, why copying the Postgres data directory is not a backup, and the restore mistake that leaves a timeline empty.
What an Immich backup must contain
An Immich backup is three things captured at the same moment. The originals under UPLOAD_LOCATION. A SQL dump of the Postgres database. The .env and docker-compose.yml that describe the stack. Restoring means replaying that dump into a fresh database while the Immich server is stopped, and starting the rest of the stack only after that. Get the order wrong and you end up with a working Immich showing an empty timeline on top of a full disk.
The split matters because Immich keeps its state in two places that know nothing about each other. Postgres holds every album, every face cluster, every shared link, every user account and API key, and the stored path of each asset. The filesystem holds the pixels. Restore the files without the database and Immich shows you nothing. Restore the database without the files and every asset opens to a broken image.
The commands here are written against Immich v3.1.0, the release current in early August 2026. The project ships fast and the documented backup procedure has changed more than once, so check the version you are actually running before you copy anything. If the stack is not up yet, start with the Immich install guide and come back.
Know what your paths point at
Two variables in .env decide everything on this page. UPLOAD_LOCATION is the parent directory Immich writes all media into. DB_DATA_LOCATION is the Postgres data directory.
The stock example.env sets UPLOAD_LOCATION=./library, which is a confusing default, because Immich then creates a folder called library inside it. Your originals end up at ./library/library. Set an absolute path instead, so a backup script can never depend on which directory you ran it from.
UPLOAD_LOCATION=/srv/immich/data
DB_DATA_LOCATION=/srv/immich/postgres
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
IMMICH_VERSION=v3.1.0Inside UPLOAD_LOCATION Immich creates several folders. Three of them hold data that no job can rebuild:
library: the originals, laid out by your storage templateupload: originals not yet moved into the template layout, plus uploads in flightprofile: user profile pictures
Lose library and the photo is gone. Immich keeps no second copy of an original anywhere.
Why copying the Postgres data directory is not a backup
DB_DATA_LOCATION looks like an easy target. It is a directory, rsync will copy it, and the copy finishes without an error. It is still not a backup, for two reasons you can watch fail.
The first is tearing. Postgres writes every change to the write-ahead log (WAL) first, then applies it to the table files later at a checkpoint. So at any instant the files on disk are mid-flight, and a rolling copy that takes four minutes reads the first file at 02:00 and the last at 02:04. Those two files do not belong to the same transaction. When you start Postgres on the result it either refuses at startup with PANIC: could not locate a valid checkpoint record, or it starts and then dies on the first read of a damaged page with invalid page in block 1234 of relation base/16384/.... Neither is recoverable from that copy.
The second reason survives even if you stop everything first. A Postgres data directory is bound to the exact binaries that wrote it. Immich pins its database image by digest, currently ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0. That is Postgres 14 with two vector-search extensions compiled in. A data directory written by that build will not open under a different Postgres major version, and it will not open under a build carrying different extension versions. Your restore host has to reproduce the image exactly. A SQL dump does not care: it is text, and any compatible server replays it.
pg_dump sidesteps the tearing problem outright. It reads the whole database inside a single MVCC (multi-version concurrency control) snapshot, so it sees the database exactly as it was at one instant while other writes carry on around it. That is why you do not have to stop Postgres to dump it.
What you can leave out of the backup
These regenerate, so you may skip them:
thumbs: preview and thumbnail imagesencoded-video: transcoded videoDB_DATA_LOCATION: rebuilt from the dump- the
model-cacheDocker volume: machine learning models, downloaded again on demand
Skipping them is a trade, not a free win. Rebuilding thumbnails and transcodes for a large library is hours of CPU on a small VPS, and the timeline shows grey placeholders the whole time. You rerun them from Administration > Jobs, with "Generate Thumbnails" and "Transcode Videos" set to run on missing assets. If your backup target has room, include them and skip the wait. If you are close to your storage limit, drop them and plan for the rebuild. Sizing an Immich library covers how large these folders grow relative to the originals.
One more folder is worth knowing about. UPLOAD_LOCATION/backups holds Immich's own automatic database dumps, written daily at 02:00 with the last 14 kept, configurable under Administration > Settings > Backup. They cost you nothing and they are genuinely useful. They also sit on the same disk as the library they protect, so they help with a bad migration and not with a dead server. Take your own dump anyway, because a dump you trigger yourself lands at the same moment as the file snapshot that belongs with it.
Take the database dump
docker exec -t immich_postgres pg_dump --clean --if-exists \
--dbname=immich --username=postgres \
| gzip > /srv/immich/backup/immich.sql.gzReplace immich and postgres with your DB_DATABASE_NAME and DB_USERNAME if you changed them. --clean --if-exists puts a DROP ... IF EXISTS in front of every CREATE, so the dump replays into a database that already holds objects instead of stopping on the first one.
Now the detail that silently ruins backup scripts. That command is a pipeline, and a shell reports the exit status of the last command in a pipeline. If pg_dump fails, on a wrong password or a container that is not running, gzip receives an empty stream, writes a perfectly valid gzip file, and exits 0. Your script logs success and you own a 20-byte backup. Put pipefail at the top of every backup script:
#!/usr/bin/env bash
set -euo pipefailThen check the result rather than trusting the exit code:
ls -lh /srv/immich/backup/immich.sql.gz
gunzip -c /srv/immich/backup/immich.sql.gz | head -n 3The first line of a healthy dump reads -- PostgreSQL database dump. A file of a few hundred bytes is a failed dump, whatever the script said.
Record which build wrote it, next to the dump:
docker inspect --format '{{.Config.Image}}' immich_server > /srv/immich/backup/immich-version.txtDo not rely on .env for this. The stock file sets IMMICH_VERSION=v3, a floating tag that follows every 3.x release, so it tells you nothing about which build actually wrote the dump. Pin the exact tag in .env as well.
Pause the server, then snapshot with restic
Files under UPLOAD_LOCATION are not immutable while Immich runs. The server writes new uploads, and the storage template job moves files between directories. If a backup tool reads a file halfway through a write, it stores those bytes as though they were the whole file, and nothing reports an error. Stop the server container for the length of the run:
docker stop immich_serverLeave immich_postgres running, because the dump needs it. The web interface and the mobile app are offline until you start the server again, which at 03:00 on a household instance is usually fine.
restic fits here because it deduplicates and encrypts before anything leaves the box. Point it at a repository that is not on this server:
export RESTIC_REPOSITORY=sftp:backup@backup.example.com:/srv/restic/immich
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic initObject storage works the same way, and is the better answer if you want the copy off your own hardware entirely:
export RESTIC_REPOSITORY=s3:https://s3.example.com/immich-backup
export AWS_ACCESS_KEY_ID=your-access-key
export AWS_SECRET_ACCESS_KEY=your-secret-key
restic initThat endpoint can be a MinIO bucket you run yourself on a second machine, or any S3-compatible provider. A repository on the same disk as the library protects you against a mistaken delete and against nothing else.
Then the snapshot, listing exactly what matters:
restic backup \
/srv/immich/backup/immich.sql.gz \
/srv/immich/backup/immich-version.txt \
/srv/immich/data/library \
/srv/immich/data/upload \
/srv/immich/data/profile \
/srv/immich/.env \
/srv/immich/docker-compose.yml
docker start immich_serverrestic reads the whole tree every run but only uploads blocks it has not seen before, so the first snapshot moves your entire library and every snapshot after it moves the day's new photos.
Retention, and the keys that must live elsewhere
restic forget --prune --keep-daily 7 --keep-weekly 5 --keep-monthly 12forget drops snapshots from the index. --prune is the half that deletes the data those snapshots were the last reference to. Run forget without --prune and your storage bill never goes down.
Structure checks are cheap, so run one weekly:
restic checkThat verifies the repository metadata is consistent. It does not read your data. Once a month, re-read a sample and check it against its recorded hashes:
restic check --read-data-subset=5%This is the only check that catches silent corruption on the storage backend, because it downloads real blocks and recomputes their checksums. A full --read-data on a photo library means downloading the entire repository, which on metered object storage costs real money, so a rolling subset is the version people actually run.
Now the part people skip. A restic repository password is not recoverable. There is no reset and no support ticket. If the only copy lives in /root/.restic-password on the server you are trying to restore, your backups are encrypted noise. The same goes for the object storage access key and for DB_PASSWORD from .env. Keep all of them somewhere that does not depend on this machine being alive: printed and in a drawer, or in a password manager running on different hardware. If that manager is self-hosted too, it needs the same treatment, and backing up Vaultwarden is its own job.
Restore Immich in the order that works
Restore order is where good backups turn into empty timelines. Follow this sequence on the new host.
Get the config back first. It tells you which version to run and where the paths point.
restic restore latest --target /restore \
--include /srv/immich/.env \
--include /srv/immich/docker-compose.yml \
--include /srv/immich/backupPin the version before anything starts. Read immich-version.txt, set IMMICH_VERSION in .env to that exact tag, and leave the newest release alone for now. Immich does not support downgrading, even between patch releases, so if a newer server starts against an older dump and runs its migrations, there is no way back.
Restore the media.
restic restore latest --target /restore --include /srv/immich/dataThen move library, upload and profile so they sit directly inside whatever UPLOAD_LOCATION points at on this host. The host path itself can change, because the compose file binds that directory to a fixed path inside the container. The layout inside it cannot change.
Start the database on its own. Leave DB_DATA_LOCATION empty so Postgres initialises a fresh cluster.
cd /srv/immich
docker compose pull
docker compose create
docker start immich_postgres
docker exec immich_postgres pg_isready --username=postgrespg_isready prints accepting connections once first-time setup has finished, which takes a few seconds. docker compose create builds every container without starting it, and that is the whole point of this step: the Immich server must not run yet. A server that starts against an empty database applies its migrations, creates a fresh schema and asks you to make a new admin account, and you are then replaying a dump underneath a running application.
Replay the dump.
gunzip --stdout /restore/srv/immich/backup/immich.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=onTwo pieces of that are doing real work. The sed exists because pg_dump writes an empty search_path into its output as a safety measure, so unqualified names in the dump cannot resolve to some unexpected schema. Immich's vector-search types live in public, so with an empty search path the restore reaches the first column declared with a vector type and psql stops with ERROR: type "vector" does not exist. Putting public back on the path fixes it.
--single-transaction --set ON_ERROR_STOP=on wraps the whole restore in one transaction that aborts on the first error. You get either a complete database or an untouched one. Without it, a failure halfway through leaves a database that starts, accepts your login, and is missing an unknown number of albums, which you discover weeks later.
Now start everything.
docker compose up -d
docker compose ps
docker logs -f immich_serverWait for a startup line like Immich Server is listening on, then open port 2283 and log in with your old credentials, because user accounts came back with the dump. If the login page instead offers to create the first admin account, the database did not restore. Stop and read the psql output again.
One warning about the official restore instructions, which open with docker compose down -v. The -v removes named volumes. In the stock compose file UPLOAD_LOCATION and DB_DATA_LOCATION are bind mounts, so they survive it. If you changed either one to a named volume, that command deletes your photos. Read your compose file before you type it.
Why the timeline is empty after a restore
The timeline is drawn from database rows. Immich never walks upload/ at boot to rediscover photos, because a file with no row has no owner, no date and no album. So the most common bad restore is files back, database missing. Immich starts, creates an empty schema, and hands you a working instance with nothing in it while the disk is full of your photos. Nothing is lost. Nothing is visible either. The fix is to replay the dump with the server stopped, exactly as above.
The second version is quieter. The database restores, the timeline fills with entries, and every asset fails to open. That means the rows point at files the container cannot see, usually because library, upload and profile are one level too deep after a restic restore --target /restore that nobody moved into place. Check from inside the container instead of guessing:
docker exec immich_server ls /dataThe stock compose file mounts UPLOAD_LOCATION at /data, so that listing should show library, upload and profile. If it shows an empty directory or a stray srv folder, your bind mount is pointing at the wrong level and the rows are fine.
Version match between backup and restore
Immich releases often and the schema moves with it, so a dump carries the schema of whatever server wrote it.
Restoring an older dump into a newer server usually works, because the server applies its pending migrations at start and walks the schema forward. That path is tested along the release sequence. Jumping several major versions in one step is where it goes wrong, and the project keeps breaking changes to major releases and documents them in its changelog.
Restoring a newer dump into an older server does not work at all. The dump holds tables and columns the older code does not know about, and Immich states that downgrading is unsupported even between patch releases. There is no rollback command to reach for.
So the safe restore is boring. Run the exact version that wrote the dump, replay it, log in, confirm the timeline is complete, and upgrade only after that. Upgrade one release at a time, bumping IMMICH_VERSION and running docker compose pull && docker compose up -d after each bump. Keeping a week of dumps helps here too: if the newest one turns out to have been taken during a failed upgrade, yesterday's is still in the repository.
Verify the backup every month
A backup you have never restored is a guess. Once a month, restore it into a throwaway instance and look at a photo. The drill takes about twenty minutes and it is the only thing that turns the rest of this page into a recovery plan.
restic snapshots
restic stats latestsnapshots should list last night's run. stats latest should report a size close to your library, not a few megabytes.
Restore into a scratch directory, ideally on a spare host:
restic restore latest --target /tmp/immich-drillCopy docker-compose.yml and .env out of the restored set, then change three things in the copy. Point UPLOAD_LOCATION and DB_DATA_LOCATION at directories under /tmp/immich-drill. Publish the web port somewhere else, 12283:2283 instead of 2283:2283. Delete the container_name: lines, because the stock compose file hard-codes names like immich_server, so a second stack on the same host collides with the first and Docker refuses to create it.
Run the restore sequence from above: database only, replay the dump, then docker compose up -d. Now do the four checks that prove something.
- Log in with the password you used before the drill. Working accounts mean the dump restored.
- Open the timeline and scroll to the oldest month. Assets across the whole date range mean all the rows came back, not just the recent ones.
- Open one photo at full size and download the original.
- Compare it against the same file in your live library with
sha256sum. Matching hashes mean the bytes survived the round trip through restic.
Then tear the drill down with docker compose down -v in the drill directory and delete /tmp/immich-drill. Write the date somewhere you will see it, because the value of this is entirely in doing it again next month. If you are still deciding which photo server to commit to, the PhotoPrism and Immich comparison covers how the two differ on exactly this ground.
FAQ
Do I have to stop Immich to back it up?
Stop immich_server and leave immich_postgres running. The database does not need a pause, because pg_dump reads inside one MVCC snapshot and sees a single consistent instant no matter what else is writing. The files are the reason to stop: the server writes new uploads and the storage template job moves files between directories, so a backup tool can read a file halfway through a write and store a truncated copy without any error. docker stop immich_server before the snapshot and docker start immich_server after it removes that race.
Can I copy the Postgres data folder instead of running pg_dump?
No. A rolling copy of a live data directory reads different files at different instants, so the result is not one consistent state, and Postgres rejects it at startup with PANIC: could not locate a valid checkpoint record or fails later on a damaged page. Even a copy taken with everything stopped is tied to the exact database build: Immich pins a Postgres 14 image with specific vector-search extension versions, and the directory will not open under anything else. A SQL dump is plain text and replays into any compatible server.
Why is my Immich timeline empty after a restore?
Because the timeline is built from database rows and you restored the files without the database. Immich never scans upload/ to rediscover photos, so files with no rows stay invisible. The photos themselves are untouched. Stop the server, replay the dump into a freshly initialised Postgres, then start the stack. If instead the timeline is full but every photo fails to open, the problem is reversed: library, upload and profile are not directly inside the directory bound into the container. Check it with docker exec immich_server ls /data.
Which Immich folders can I skip in a backup?
thumbs and encoded-video regenerate from the originals, and DB_DATA_LOCATION rebuilds from the dump, so none of them has to be in the backup set. Skipping them spends time after a restore instead of storage before it, since rebuilding previews and transcodes for a large library is hours of CPU, run from Administration > Jobs against missing assets. What you can never skip is library, upload and profile, which hold the only copy of every original.
Can I restore an Immich dump into a newer version?
Usually yes, because the server applies its pending migrations at start and walks the schema forward. The reverse fails: Immich does not support downgrading, even between patch releases, so a dump from a newer release cannot be loaded into an older server. Restore with IMMICH_VERSION pinned to the release that wrote the dump, confirm the timeline is complete, and upgrade after that. Record the version beside each dump with docker inspect --format '{{.Config.Image}}' immich_server, because the default IMMICH_VERSION=v3 is a floating tag that tells you nothing.