Guide
Backing up Postgres in Docker Compose
Dump from inside the container, write to a bind mount, and never copy the data volume of a running database. Three rules, one script.
The command
Add a bind mount for the dumps to your database service, so the file lands on the host and survives the container:
# compose.yaml
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
- ./backups:/backups
Then dump inside the container, straight into that directory:
docker compose exec -T db \
pg_dump -U postgres -Fc -d mydb -f /backups/mydb-$(date +%F).dump
# roles, passwords and grants live in the cluster, not the database
docker compose exec -T db \
pg_dumpall -U postgres --globals-only -f /backups/globals-$(date +%F).sql
$(date +%F) is expanded by your shell on the host before Docker sees it — that is what you want.
Why -T, and why from inside
-Tdisables TTY allocation. Without it the command works when you type it and fails from cron or a CI runner withthe input device is not a TTY. If you ever pipe a dump through stdout, a TTY also corrupts the bytes. Put-Tin every non-interactivedocker compose exec.- Running
pg_dumpinside the container means the client tools match the server exactly. That single decision removes a whole class of failure: a host withpg_dump14 pointed at a Postgres 16 container aborts with a server version mismatch, and an oldpg_restorefed a newer archive fails with "unsupported version in file header". - Authentication: the official image's generated
pg_hba.conftrusts local socket connections, so-U postgresinside the container needs no password. If you have replacedpg_hba.conf, pass the password explicitly:docker compose exec -T -e PGPASSWORD="$POSTGRES_PASSWORD" db pg_dump ...
Not using Compose? The same command with plain Docker: docker exec -t -i becomes just docker exec, e.g. docker exec my-db pg_dump -U postgres -Fc -d mydb -f /backups/mydb.dump.
Copying the volume is not a backup
The most common Docker Postgres "backup" is a tar or cp of the pgdata volume while the container is running. That produces a torn copy: Postgres is writing pages and WAL as you read, so the files you capture never existed together at any single instant. It may start. It may start and be silently corrupt.
A file-level copy of a live cluster is only valid as part of a proper physical backup — pg_backup_start(), the copy, pg_backup_stop(), plus the WAL generated in between — or from a stopped container. If you want a volume copy, stop the database first:
docker compose stop db
docker run --rm -v myproject_pgdata:/data -v "$PWD":/out alpine \
tar czf /out/pgdata.tar.gz -C /data .
docker compose start db
For anything that has to stay up, use the logical dump above. The difference between logical and physical backups is worth ten minutes of your time before you choose.
The nightly script
#!/usr/bin/env bash
# /opt/myproject/backup.sh
set -euo pipefail
cd /opt/myproject
STAMP=$(date +%F-%H%M)
docker compose exec -T db pg_isready -U postgres -d mydb
docker compose exec -T db \
pg_dump -U postgres -Fc -d mydb -f "/backups/mydb-$STAMP.dump"
docker compose exec -T db \
pg_dumpall -U postgres --globals-only -f "/backups/globals-$STAMP.sql"
# offsite — a copy on the same box dies with the box
rclone copy ./backups remote:mydb-backups --max-age 25h
# keep 14 days locally
find ./backups -name '*.dump' -mtime +14 -delete
set -euo pipefail is doing real work here: without -e, a failed dump is followed by a cheerful upload of yesterday's file and a cron job that exits 0. Cron entry, with the absolute path Docker needs:
0 3 * * * /opt/myproject/backup.sh >> /var/log/pg-backup.log 2>&1
Restore into a throwaway container
The dump is a hypothesis until this has run. Same major version as production, a disposable container, no impact on anything:
docker run -d --name drill -e POSTGRES_PASSWORD=drill -p 5499:5432 postgres:16
# wait for it, then load globals first, then the database
until docker exec drill pg_isready -U postgres; do sleep 1; done
psql -h localhost -p 5499 -U postgres -f backups/globals-2026-09-07.sql
createdb -h localhost -p 5499 -U postgres mydb
pg_restore --no-owner -h localhost -p 5499 -U postgres -d mydb \
backups/mydb-2026-09-07.dump
# does it look like production?
psql -h localhost -p 5499 -U postgres -d mydb -c \
"SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
docker rm -f drill
Row counts from pg_stat_user_tables are estimates — good enough to catch an empty table, not good enough to certify a restore. The full checklist (counts against a manifest, FK validation, probe queries) is the 5-step drill.
Running this under Coolify or Dokploy? They wrap the same mechanics with a scheduler and an S3 destination: Coolify, Dokploy.
One more container, and the drill runs itself
pgProof's self-hosted agent sits next to your database: dumps on schedule, encrypts locally, pushes to your bucket, then restore-tests every backup in a disposable container and emails the proof. Credentials never leave your server.
Join the early-access waitlist