SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

How to back up and restore Immich for VPS

Learn wetin Immich backup must contain, why copying the Postgres data directory no be backup, and how wrong restore order fit leave your timeline empty.

Wetin Immich backup must contain

Immich backup na three things wey you capture for the same time. The originals wey dey under UPLOAD_LOCATION. A SQL dump of the Postgres database. The .env and docker-compose.yml wey describe the stack. To restore am, replay that dump inside fresh database while Immich server dey stopped, then start the rest of the stack only after that. If you mix the order, you fit end up with working Immich wey dey show empty timeline on top full disk.

This split matter because Immich dey keep im state for two places wey no know anything about each other. Postgres dey hold every album, every face cluster, every shared link, every user account and API key, plus the stored path of each asset. Filesystem dey hold the pixels. If you restore the files without the database, Immich no go show you anything. If you restore the database without the files, every asset go open as broken image.

The commands here dey target Immich v3.1.0, wey be the release current for early August 2026. The project dey release updates fast, and the documented backup procedure don change more than once. So check the version wey you dey run before you copy anything. If the stack never come up, start with Immich installation guide and come back.

Know wetin your paths dey point to

Two variables for .env dey decide everything for this page. UPLOAD_LOCATION na the parent directory wey Immich dey write all media into. DB_DATA_LOCATION na the Postgres data directory.

The default example.env sets UPLOAD_LOCATION=./library. This default dey confusing because Immich go create folder wey dem call library inside am. Your originals go end up for ./library/library. Set absolute path instead, so backup script no go ever depend on the directory wey you run am from.

UPLOAD_LOCATION=/srv/immich/data
DB_DATA_LOCATION=/srv/immich/postgres
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
IMMICH_VERSION=v3.1.0

Inside UPLOAD_LOCATION, Immich dey create several folders. Three of dem hold data wey no job fit rebuild:

  • library: the originals, arranged according to your storage template
  • upload: originals wey never move enter the template layout, plus uploads wey still dey process
  • profile: user profile pictures

If you lose library, the photo don go. Immich no dey keep another copy of any original anywhere.

Why copying the Postgres data directory no be backup

DB_DATA_LOCATION look like easy target. E be directory, rsync go copy am, and the copy go finish without error. E still no be backup, for two reasons wey you fit watch fail.

The first one na tearing. Postgres first dey write every change to the write-ahead log (WAL), then e apply am to the table files later for checkpoint. So for any moment, the files for disk dey midway through update. A rolling copy wey take four minutes fit read the first file for 02:00 and the last one for 02:04. Those two files no belong to the same transaction. When you start Postgres with the result, e either refuse to start with PANIC: could not locate a valid checkpoint record, or e go start and later die when e first read damaged page with invalid page in block 1234 of relation base/16384/.... You no fit recover either case from that copy.

The second reason still apply even if you stop everything first. Postgres data directory dey tied to the exact binaries wey write am. Immich pin its database image by digest, currently ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0. Na Postgres 14 with two vector-search extensions wey dem compile inside. Data directory wey that build write no go open under another Postgres major version. E no go open under build wey carry different extension versions too. Your restore host must reproduce the image exactly. SQL dump no get this problem: na text e be, and any compatible server fit replay am.

pg_dump avoid the tearing problem completely. E read the whole database inside one MVCC (multi-version concurrency control) snapshot, so e see the database exactly as e be for one moment while other writes continue around am. Na why you no need stop Postgres before you dump am.

Wetin you fit leave out for backup

These ones dey regenerate, so you fit skip dem:

  • thumbs: preview and thumbnail images
  • encoded-video: transcoded video
  • DB_DATA_LOCATION: rebuilt from the dump
  • the model-cache Docker volume: machine learning models, wey go download again when e dey needed

Skipping dem na trade-off, no be free benefit. To rebuild thumbnails and transcodes for large library fit use hours of CPU for small VPS, and timeline go show grey placeholders throughout that time. You go run dem again from Administration > Jobs, with "Generate Thumbnails" and "Transcode Videos" set to run for missing assets. If backup target get enough space, include dem and avoid the wait. If storage limit don nearly full, remove dem and plan for the rebuild. How to size an Immich library explain how these folders dey grow compared with the original files.

One more folder dey wey you suppose know. UPLOAD_LOCATION/backups dey keep Immich own automatic database dumps. E dey write dem every day for 02:00 and keep the last 14. You fit configure am under Administration > Settings > Backup. Dem no cost anything and dem really useful. But dem dey for the same disk with the library wey dem protect, so dem fit help for bad migration, but dem no go help if server die. Still take your own dump, because dump wey you trigger yourself go land at the same time with the file snapshot wey belong with am.

Database dump take

docker exec -t immich_postgres pg_dump --clean --if-exists \
  --dbname=immich --username=postgres \
  | gzip > /srv/immich/backup/immich.sql.gz

Replace immich and postgres with your DB_DATABASE_NAME and DB_USERNAME if you change dem. --clean --if-exists go put one DROP ... IF EXISTS for front of every CREATE, so the dump go replay inside database wey already get objects instead of stopping for the first one.

Now, na this detail dey quietly spoil backup scripts. That command na pipeline, and shell dey report the exit status of the last command for pipeline. If pg_dump fail, because password wrong or container no dey run, gzip go receive empty stream, write one valid gzip file, and exit 0. Your script go log success, but na 20-byte backup you get. Put pipefail for top of every backup script:

#!/usr/bin/env bash
set -euo pipefail

Then check the result instead of trusting the exit code:

ls -lh /srv/immich/backup/immich.sql.gz
gunzip -c /srv/immich/backup/immich.sql.gz | head -n 3

The first line of healthy dump dey read -- PostgreSQL database dump. File wey get only few hundred bytes na failed dump, no matter wetin the script talk.

Record which build write am, beside the dump:

docker inspect --format '{{.Config.Image}}' immich_server > /srv/immich/backup/immich-version.txt

No depend on .env for this. The stock file set IMMICH_VERSION=v3, na floating tag wey dey follow every 3.x release, so e no tell you which build actually write the dump. Pin the exact tag for .env too.

Pause server, then use restic take snapshot

Files wey dey under UPLOAD_LOCATION no be immutable while Immich dey run. Server dey write new uploads, and storage template job dey move files between directories. If backup tool read file while write still dey happen, e go store those bytes as if dem be the complete file, and nothing go report error. Stop server container for the time wey the run go take:

docker stop immich_server

Make immich_postgres continue to run, because dump need am. Web interface and mobile app go dey offline until you start server again. For household instance wey start for 03:00, this one usually no be problem.

restic fit work well here because e dey deduplicate and encrypt data before anything comot from the server. Point am to repository wey no dey this server:

export RESTIC_REPOSITORY=sftp:backup@backup.example.com:/srv/restic/immich
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic init

Object storage dey work the same way, and na better option if you want make the copy dey completely outside your own hardware:

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 init

That endpoint fit be MinIO bucket wey you run yourself for second machine, or any S3-compatible provider. Repository wey dey the same disk with the library go protect you against accidental delete, but e no go protect you from anything else.

Then take the snapshot, and list exactly wetin matter:

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_server

restic dey read the whole tree for every run, but e only upload blocks wey e never see before. So the first snapshot go move your complete library, and every snapshot after that go move the new photos for that day.

Retention, and the keys wey must dey elsewhere

restic forget --prune --keep-daily 7 --keep-weekly 5 --keep-monthly 12

forget dey remove snapshots from the index. --prune na the part wey dey delete the data wey those snapshots bin be the last reference to. Run forget without --prune and your storage bill no go reduce.

Structure checks cheap, so run one every week:

restic check

This one dey verify say the repository metadata consistent. E no dey read your data. Once every month, read one sample again and check am against the hashes wey dem record for am:

restic check --read-data-subset=5%

Na only this check fit catch silent corruption for the storage backend, because e dey download real blocks and recompute their checksums. A full --read-data for a photo library mean say e go download the entire repository. For metered object storage, this one cost real money, so na rolling subset be the version people actually dey run.

Now make we talk about the part wey people dey skip. You no fit recover a restic repository password. Reset no dey, and support ticket no go solve am. If the only copy dey inside /root/.restic-password for the server wey you dey try restore, your backups don turn to encrypted noise. The same thing apply to the object storage access key and DB_PASSWORD from .env. Keep all of dem for somewhere wey no depend on this machine being alive: print dem and keep dem for drawer, or use password manager wey dey run for different hardware. If that manager self-hosted too, e need the same protection, and backing up Vaultwarden na separate work.

Restore Immich for the correct order

Restore order na the difference between backup wey work and empty timelines. Follow this sequence for the new host.

Bring the config back first. E dey tell you which version to run and where the paths dey point.

restic restore latest --target /restore \
  --include /srv/immich/.env \
  --include /srv/immich/docker-compose.yml \
  --include /srv/immich/backup

Pin the version before anything starts. Read immich-version.txt, set IMMICH_VERSION for .env to that exact tag, and leave the newest release for now. Immich no support downgrading, even between patch releases. So if newer server start against older dump and run its migrations, no way dey go back.

Restore the media.

restic restore latest --target /restore --include /srv/immich/data

Then move library, upload and profile make dem sit directly inside anywhere UPLOAD_LOCATION point to for this host. The host path fit change, because the compose file dey bind that directory to fixed path inside the container. But the layout inside am no fit change.

Start the database by itself. Leave DB_DATA_LOCATION empty make Postgres initialise fresh cluster.

cd /srv/immich
docker compose pull
docker compose create
docker start immich_postgres
docker exec immich_postgres pg_isready --username=postgres

pg_isready go print accepting connections after first-time setup finish, and this one dey take few seconds. docker compose create go build every container without starting any, and na the main reason for this step: Immich server must not run yet. If server start against empty database, e go apply migrations, create fresh schema and ask you to make new admin account. Then you go dey replay dump underneath application wey dey run.

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=on

Two parts for this command dey do real work. sed dey there because pg_dump writes empty search_path into its output as safety measure. This stops unqualified names for the dump from resolving to unexpected schema. Immich vector-search types dey inside public, so with empty search path, restore go reach the first column wey use vector type and psql go stop with ERROR: type "vector" does not exist. Put public back on the path to fix am.

--single-transaction --set ON_ERROR_STOP=on wraps the whole restore inside one transaction wey go abort for the first error. You go get either complete database or database wey no change. Without am, failure halfway through go leave database wey starts, accepts your login, but dey miss unknown number of albums. You fit discover this weeks later.

Now start everything.

docker compose up -d
docker compose ps
docker logs -f immich_server

Wait for startup line like Immich Server is listening on. Then open port 2283 and log in with your old credentials, because user accounts come back with the dump. If login page instead offer to create first admin account, database no restore. Stop and read the psql output again.

One warning about the official restore instructions, wey start with docker compose down -v. -v removes named volumes. For the stock compose file, UPLOAD_LOCATION and DB_DATA_LOCATION na bind mounts, so dem go survive it. If you change either one to named volume, that command go delete your photos. Read your compose file before you type am.

Why timeline empty after restore

Database rows dey draw the timeline. Immich no dey scan upload/ for boot to find photos again, because file wey no get row no get owner, date, or album. So the commonest bad restore na say files don return, but database no dey. Immich starts, creates empty schema, then gives you instance wey dey work but nothing dey inside, while your photos full the disk. Nothing don loss. But nothing dey visible too. To fix am, replay the dump while server stop, exactly as e be above.

The second version dey quieter. Database restores, timeline fills with entries, but every asset fail to open. That means the rows point to files wey the container no fit see. Usually, library, upload, and profile dey one level too deep after a restic restore --target /restore wey nobody move enter the correct place. Check from inside the container instead of guessing:

docker exec immich_server ls /data

The stock compose file mounts UPLOAD_LOCATION for /data, so that listing suppose show library, upload, and profile. If e show empty directory or stray srv folder, your bind mount dey point to the wrong level, and the rows dey correct.

Version match between backup and restore

Immich dey release often, and schema dey move together with am. So dump dey carry schema of the server wey write am.

To restore older dump inside newer server normally dey work, because server dey apply pending migrations when e start and move schema forward. Dem don test this process along the release sequence. E dey fail when you jump several major versions at once. The project keeps breaking changes for major releases and documents dem for changelog.

To restore newer dump inside older server no dey work at all. The dump get tables and columns wey older code no know about. Immich talk say downgrading no dey supported, even between patch releases. No rollback command dey available.

So safe restore dey straightforward. Run the exact version wey write the dump, replay am, log in, and confirm say the timeline complete. Upgrade only after that. Upgrade one release at a time. Increase IMMICH_VERSION and run docker compose pull && docker compose up -d after each upgrade. Keeping one week of dumps dey help here too. If the newest one turn out say dem take am during failed upgrade, yesterday one still dey for repository.

Verify the backup every month

Backup wey you never restore na just guess. Once every month, restore am into temporary instance wey you fit throw away, then check one photo. This test dey take about twenty minutes, and na only this one dey turn the rest of this page into real recovery plan.

restic snapshots
restic stats latest

snapshots suppose list last night run. stats latest suppose report size wey near your library size, no be just few megabytes.

Restore am into scratch directory, preferably for spare host:

restic restore latest --target /tmp/immich-drill

Copy docker-compose.yml and .env comot from the restored set, then change three things for the copy. Point UPLOAD_LOCATION and DB_DATA_LOCATION to directories under /tmp/immich-drill. Publish the web port for another place, 12283:2283 instead of 2283:2283. Delete the container_name: lines, because the stock compose file hard-codes names like immich_server. This one make second stack for the same host collide with the first one, and Docker refuse to create am.

Run the restore sequence from above: database only, replay the dump, then docker compose up -d. Now do these four checks wey go prove say e work.

  1. Log in with the password wey you use before the test. If the account work, the dump restore well.
  2. Open the timeline and scroll go the oldest month. If assets dey across the whole date range, all the rows don come back, no be only the recent ones.
  3. Open one photo at full size and download the original.
  4. Compare am with the same file for your live library using sha256sum. If the hashes match, the bytes survive the round trip through restic.

After that, tear down the test with docker compose down -v inside the drill directory and delete /tmp/immich-drill. Write the date for somewhere wey you go see am, because the value of this whole process dey inside doing am again next month. If you never decide which photo server to commit to, the comparison between PhotoPrism and Immich explain how the two differ for this exact matter.

FAQ

I need stop Immich before I back am up?

Stop immich_server and leave immich_postgres running. Database no need pause, because pg_dump dey read inside one MVCC snapshot and dey see one consistent moment, no matter wetin dey write. Na the files make stopping necessary: server dey write new uploads, and storage template job dey move files between directories. So backup tool fit read file halfway through write and save truncated copy without any error. docker stop immich_server before the snapshot and docker start immich_server after am remove that race.

I fit copy Postgres data folder instead of running pg_dump?

No. Rolling copy of live data directory dey read different files at different moments. So the result no be one consistent state, and Postgres go reject am at startup with PANIC: could not locate a valid checkpoint record or fail later because page don damage. Even copy wey you take when everything stop still depend on exact database build: Immich pin Postgres 14 image with specific vector-search extension versions, and directory no go open under anything else. SQL dump na plain text, and e fit replay into any compatible server.

Why my Immich timeline empty after restore?

Na because timeline dey come from database rows, but you restore the files without the database. Immich no dey scan upload/ to find photos again, so files wey no get rows go remain invisible. The photos themselves no spoil. Stop server, replay the dump into freshly initialised Postgres, then start the stack. But if timeline full and every photo no open, the problem don reverse: library, upload and profile no dey directly inside directory wey you bind into the container. Check am with docker exec immich_server ls /data.

Which Immich folders I fit leave out for backup?

thumbs and encoded-video dey regenerate from the originals, while DB_DATA_LOCATION dey rebuild from the dump, so none of dem must dey inside backup set. If you skip dem, you go spend time after restore instead of storage before restore, because rebuilding previews and transcodes for large library fit take hours of CPU. Run am from Administration > Jobs against missing assets. Wetin you no fit ever skip na library, upload and profile, because dem hold the only copy of every original.

I fit restore Immich dump into newer version?

Usually yes, because server dey apply pending migrations when e start and move schema forward. Reverse one no go work: Immich no support downgrading, even between patch releases, so dump from newer release no fit load into older server. Restore with IMMICH_VERSION pinned to the release wey write the dump, confirm say timeline complete, then upgrade after that. Record the version beside each dump with docker inspect --format '{{.Config.Image}}' immich_server, because default IMMICH_VERSION=v3 na floating tag wey no tell you anything.