Last updated: November 2026 — commands tested against Docker Compose v2 + Postgres 14–16 style images used by Immich/Paperless stacks.
For: Anyone self-hosting Compose apps that store state in bind mounts or named volumes, especially Postgres. You need backups you can restore without a silent corrupt database.
Do not use this guide when:
You only run SQLite apps and already cold-copy the single data dir (still fine to read the “filesystem copy” section)
You expect a host cp -a of a live Postgres data directory to be crash-safe (it is not)
Your “backup” is a VPS snapshot alone with no app-level restore drill
You need continuous PITR / WAL shipping (out of scope — use managed Postgres or Barman)
Method
Consistent for Postgres?
Consistent for files?
Notes
pg_dump / pg_dumpall while DB is up
Yes (logical)
N/A
Preferred default
Stop Postgres, then tar data dir
Yes (cold)
Yes
Longer downtime
tar / rsync while Postgres is writing
No
Maybe
Classic silent corruption risk
Volume plugin / filesystem snapshot (LVM, ZFS, cloud disk snap) with DB flush
Yes if coordinated
Yes
Advanced; still verify with restore
Copy only UPLOAD_LOCATION / media
N/A
Yes for blobs
Useless without matching DB for Immich/Paperless
Rule: For Postgres, prefer a logical dump. Treat raw PGDATA copies as cold or snapshot-coordinated only.
Resource
Floor
Notes
Free disk
2× size of dump + media you keep on-box
Dumps fail mid-write if disk fills
RAM
Whatever the app needs
pg_dump is light; Immich/Paperless dominate
Off-box target
Another VPS, S3-compatible, or laptop
Same-disk “backup” dies with the VPS
~/immich/ # example — same patterns for Paperless
docker-compose.yml
.env
library/ # bind mount media
postgres/ # bind mount PGDATA OR a named volume
Discover mounts:
docker compose ps
docker compose config | less
docker volume ls
docker inspect <postgres_container> --format '{{json .Mounts}}' | jq
Works while the database container is running.
cd ~/immich # or your project
mkdir -p ~/backups
docker compose exec -T database \
pg_dump -U "$DB_USERNAME" -d "$DB_DATABASE_NAME" -Fc \
> ~/backups/immich-db-$(date +%F).dump
-Fc is custom format (good with pg_restore). Plain SQL:
docker compose exec -T database \
pg_dump -U postgres -d immich \
> ~/backups/immich-db-$(date +%F).sql
docker compose exec -T database \
pg_dumpall -U postgres \
> ~/backups/pg-all-$(date +%F).sql
Use pg_dumpall when you are unsure which DBs/extensions matter (Immich Postgres images include extensions — restore onto the same family of image).
Name
Meaning
Example
DB_USERNAME / -U
Postgres role
postgres
DB_DATABASE_NAME / -d
Database name
immich
DB_PASSWORD
Auth (often via container env already)
alphanumeric for Immich
Compose service name
Must match exec target
database
If exec asks for a TTY, keep -T when redirecting to a file on the host.
Stop writers that depend on the DB (app servers), then Postgres:
docker compose stop immich-server immich-machine-learning
docker compose stop database
Copy:
Bind mount
tar -czf ~/backups/pgdata-$(date +%F).tar.gz -C ./postgres .
Named volume
docker run --rm \
-v immich_postgres:/from:ro \
-v ~/backups:/backups \
alpine tar -czf /backups/pgdata-$(date +%F).tar.gz -C /from .
(Adjust volume name via docker volume ls.)
Start again: docker compose start database then the apps.
Cold copy downtime is usually minutes for small Immich DBs; media rsync is separate and can run longer.
Run with a matching DB dump from the same window when possible.
rsync -aH --delete ./library/ ~/backups/library/
# or
tar -czf ~/backups/library-$(date +%F).tar.gz -C ./library .
For Paperless: back up media + data + export the DB the same day.
#!/usr/bin/env bash
set -euo pipefail
PROJECT=~/immich
OUT=~/backups/$(date +%F)
mkdir -p "$OUT"
cd "$PROJECT"
docker compose exec -T database pg_dumpall -U postgres > "$OUT/pg.sql"
rsync -aH ./library/ "$OUT/library/"
cp -a .env "$OUT/env.copy"
# rclone copy "$OUT" remote:immich-backups/
Keep at least 7 daily dumps if disk allows; test restore monthly.
docker compose up -d database
# wait until healthy
docker compose exec -T database \
pg_restore -U postgres -d immich --clean --if-exists < ~/backups/immich-db-YYYY-MM-DD.dump
Plain SQL:
docker compose exec -T database \
psql -U postgres -d postgres < ~/backups/pg-all-YYYY-MM-DD.sql
Then restore media paths to the locations in .env, start app containers, open the UI.
docker compose down
Move aside broken ./postgres (do not delete until verified)
Extract tarball into empty data dir with correct ownership (Postgres images often expect UID 999 or the image’s postgres user)
docker compose up -d
If Postgres refuses to start, read logs — wrong permissions and wrong major version are the usual causes
Never restore a Postgres 16 data directory into a Postgres 14 image (or the reverse) and expect it to work.
Backups do not replace TLS. After restore to a new VPS:
Point DNS at the new IP
Bring up shared or per-stack Caddy on 80/443
Confirm app public URL env still matches https://hostname
Keep Caddyfile + .env in the secret backup set.
Symptom
Cause
Fix
Dump file is 0 bytes
Wrong service name / auth / redirected without -T
docker compose ps; add -T; check user/db
pg_restore: error: input file appears to be a text format dump
Used pg_restore on plain SQL
Use psql < file.sql instead
Immich/Paperless starts but library empty / broken thumbs
Restored DB without media (or reverse)
Restore both from same date
Postgres boot: permission denied on data dir
Host UID/GID mismatch after untar
Fix ownership to image postgres UID; avoid NFS for PGDATA
Corrupt relation / checksum errors after “backup”
Live tar of PGDATA
Discard; restore from pg_dump or cold copy
Disk full mid-dump
No headroom
df -h; prune images; dump to another disk
Extensions missing after restore
Restored into vanilla postgres image
Use the app’s Postgres image (e.g. Immich’s vector image)
Compose interpolation emptied password in cron
Unquoted $ in .env sourced by shell
Keep secrets in Compose env; do not source .env carelessly
[ ] Today’s dump file size > 0 and grows vs empty schema
[ ] head/file on dump looks like SQL or custom archive, not an error string
[ ] Throwaway directory restore: DB up + login works
[ ] One media file sampled from backup matches production
[ ] Off-box copy exists (second machine or object storage)
[ ] Calendar reminder: restore drill at least quarterly
Situation
Snapshot OK as sole backup?
Power-off / provider freeze then snap
Better, still verify
Live snap under heavy Postgres write
Risky alone
Ransomware / bad compose down -v
Snapshot helps only if retained off the same panel mistakes
Need file-level restore of one DB
Logical dump wins
Use snapshots as layer 2, dumps as layer 1.
Pin the Postgres image major version in compose. Document which dump command you standardized on (pg_dump vs pg_dumpall) next to the cron entry.