SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

How to backup and restore Immich for VPS

Know wetin Immich backup must carry, why live Postgres data directory no be backup, and the restore mistake wey fit leave timeline empty.

Immich backup dey need to contain

Immich backup na three things wey you capture for the same time. The original files 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 into a 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 of full disk.

This split matter because Immich dey keep its 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 for each asset. The filesystem dey hold the pixels. If you restore the files without the database, Immich no go show anything. If you restore the database without the files, every asset go open as broken image.

The commands for here target Immich v3.1.0, wey be the release current for early August 2026. As of 2026-10-21, the current release na v3.3.4, published 2026-10-20, and its compose file, database image, plus documented backup and restore steps match wetin this page dey use. The project dey release 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 dey up, start with the Immich install 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 fit look like easy thing to copy. Na directory, rsync go copy am, and the copy go finish without error. If you copy am while Postgres dey run, that copy no be backup. If you copy am when Postgres stop, e be backup, but e fragile. Na these two reasons cause am, and you fit watch both fail.

The first one na tearing. Postgres dey write every change to the write-ahead log (WAL) first, then e apply the change to the table files later during checkpoint. So at any moment, the files for disk dey somewhere inside the write process. A rolling copy wey take four minutes fit read the first file at 02:00 and the last one at 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 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 concern the copy wey you make when everything stop. PostgreSQL documentation say complete copy of data directory wey shut down valid as backup, but na only binaries wey share the same storage format fit open that directory. Immich pins its database image by digest, currently ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0. Na Postgres 14 with VectorChord, pgvector and pgvecto.rs vector-search extensions built inside. Later Postgres 14 minor release fit open the directory unchanged, but different Postgres major version no fit. The extensions wey your tables depend on must also dey available, with compatible versions. Your restore host must run compatible image. SQL dump no get this problem: na text e be, and any compatible server fit replay am.

pg_dump remove 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 at 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

Dem dey regenerate, so you fit skip dem:

  • thumbs: preview and thumbnail images
  • encoded-video: transcoded video
  • DB_DATA_LOCATION: dem rebuild am from the dump
  • the model-cache Docker volume: machine learning models, dem go download am again when e need am

Skipping dem na trade-off, e no be free benefit. To rebuild thumbnails and transcodes for big library fit use hours of CPU for small VPS, and timeline go show grey placeholders throughout that time. You rerun dem from Administration > Jobs, with "Generate Thumbnails" and "Transcode Videos" set to run for missing assets. If your 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 big these folders fit grow compared with the original files.

One more folder dey worth knowing. UPLOAD_LOCATION/backups dey hold Immich own automatic database dumps. E dey write dem daily at 02:00, and e keep the last 14. You fit configure am under Administration > Settings > Database Dump Settings. Dem no cost you anything, and dem really useful. But dem dey the same disk with the library wey dem protect, so dem fit help when migration spoil, not when server die completely. Still take your own dump, because dump wey you trigger yourself go land at the same time with the file snapshot wey belong to am.

Take the database dump

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 don change dem. --clean --if-exists dey put DROP ... IF EXISTS before every CREATE, so the dump go replay inside database wey already get objects instead of stopping for the first one.

Now make we look the detail wey fit 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, maybe because password wrong or container no dey run, gzip go receive empty stream, write gzip file wey valid well-well, and exit 0. Your script go log success, but you go get backup wey na only 20-byte. 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 for healthy dump dey read -- PostgreSQL database dump. If file na only few hundred bytes, the dump fail, no matter wetin the script talk.

Record the release wey write am beside the dump by asking the server wey dey run:

curl -fsS http://localhost:2283/api/server/version > /srv/immich/backup/immich-version.txt

That endpoint no need login and e dey return the running release as major, minor, and patch numbers. So 3, 1 and 0 mean v3.1.0. No use .env for this, and no use docker inspect --format '{{.Config.Image}}' immich_server too, because e only dey echo the image reference wey you configure. The stock file dey set IMMICH_VERSION=v3, na floating tag wey dey follow every 3.x release. So if record end with :v3, e no tell you which build really 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 order wey go work

Restore order na where good backups fit turn into empty timelines. Follow this sequence for the new host.

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

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 inside .env to that exact release (v3.1.0 for major 3, minor 1, patch 0), and leave the newest release alone for now. Immich no support downgrading, even between patch releases. So if newer server starts against older dump and runs its migrations, no way dey to reverse am.

Restore the media.

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

Then move library, upload and profile so dem dey directly inside wherever UPLOAD_LOCATION points to for this host. The host path fit change, because the compose file binds 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 so Postgres fit initialise a 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 prints accepting connections after first-time setup finish, and this one dey take a few seconds. docker compose create builds every container without starting any, and na the main reason for this step: Immich server must not run yet. Server wey starts against empty database go apply its migrations, create fresh schema, and ask you to make new admin account. Then you go dey replay a dump under 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=on

Two parts of this command dey do the real work. sed dey there because pg_dump writes empty search_path into its output as safety measure. This stops unqualified names inside the dump from resolving to unexpected schema. Immich vector-search types dey inside public. So when search path empty, restore reaches the first column declared with vector type and psql stops with ERROR: type "vector" does not exist. Putting public back on the path fixes the problem.

--single-transaction --set ON_ERROR_STOP=on wraps the whole restore inside one transaction wey go abort at the first error. You go get either complete database or database wey no change. Without am, failure for the middle fit leave database wey starts, accepts your login, but dey miss unknown number of albums. You fit only 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 don return with the dump. If the login page instead offers to create the first admin account, the database no restore. Stop and read the psql output again.

One warning about the official command-line 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 am. 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 di backup every month

Backup wey you never restore na guesswork. Once every month, restore am inside a throwaway instance and look one photo. The drill dey take about twenty minutes, and na only this one fit turn the rest of this page into recovery plan.

restic snapshots
restic stats latest

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

Restore am inside 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-code names like immich_server. So, second stack for the same host go clash with the first one, and Docker go 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 drill. If the accounts dey work, e mean say the dump restore correctly.
  2. Open the timeline and scroll go the oldest month. Assets across the complete date range mean say all the rows come back, no be only the recent ones.
  3. Open one photo for full size and download the original.
  4. Compare am with the same file for your live library using sha256sum. Matching hashes mean say the bytes survive the round trip through restic.

After that, tear down the drill with docker compose down -v for the drill directory and delete /tmp/immich-drill. Write the date for somewhere wey you go see am, because the value of this work dey entirely on repeating am next month. If you still dey decide which photo server to commit to, the comparison between PhotoPrism and Immich explain how the two differ for exactly this 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 stop necessary: server dey write new uploads, and storage template job dey move files between directories. So backup tool fit read file while write still dey happen and save truncated copy without any error. docker stop immich_server before the snapshot and docker start immich_server after am removes that race.

I fit copy Postgres data folder instead of running pg_dump?

No, not while Postgres dey run. Rolling copy of live data directory dey read different files at different moments. So the result no be one consistent state, and Postgres rejects am at startup with PANIC: could not locate a valid checkpoint record or later fails because one page don damage. Complete copy wey you take after stopping Postgres na valid backup, but e go only open under the same Postgres major version with compatible extensions. Immich pins Postgres 14 image wey carry VectorChord, pgvector and pgvecto.rs, and directory no go open under another major version. SQL dump na plain text and e fit replay into any compatible server, so na that copy you still need keep.

Why my Immich timeline empty after restore?

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

Which Immich folders I fit leave out of 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. You run am from Administration > Jobs against missing assets. The ones you never fit 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. The reverse no 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 release beside each dump with curl -fsS http://localhost:2283/api/server/version, because docker inspect only echoes the configured image reference, and default IMMICH_VERSION=v3 na floating tag wey no tell you anything.