Databases fail for many reasons: a bad deployment, a dropped table, disk corruption, or a destructive query run against the wrong environment. When PostgreSQL runs in Docker, there’s one more risk to think about. If the container’s volume is removed and you don’t have a backup, your data is gone for good.
In this guide, we’ll walk through how to back up PostgreSQL in Docker with pg_dump, run those backups on a schedule, prune old files, encrypt them, copy them off the server, and restore them when you need to. Every command here was tested on PostgreSQL 18, the current stable release, and works the same on PostgreSQL 13 and later.
Running MySQL or MariaDB? Read our guide to automated MySQL and MariaDB backups in Docker.
Quick start: back up and restore in two commands
Back up a database from a running container named postgres:
docker exec postgres pg_dump -U myuser -d mydb -Fc > mydb.dumpRestore it into an empty database:
docker exec postgres createdb -U myuser mydb_restoreddocker exec -i postgres pg_restore -U myuser -d mydb_restored --no-owner < mydb.dumpThe rest of this guide covers how to make this automatic, store the backups safely, and test that they actually work.
Logical vs physical backups
Logical (pg_dump) | Physical (pg_basebackup, WAL archiving) | |
|---|---|---|
| Output | SQL or an archive of schema and data | A copy of the data directory files |
| Restore to a newer major version | Yes | No |
| Point-in-time recovery | No, only to the time of the dump | Yes, with WAL archiving |
| Speed on large databases | Slow to dump and restore | Fast |
| Setup | One command | More configuration |
For most applications under roughly 50 GB, pg_dump is the right choice, and it’s what we’ll use for the rest of this guide. If your database is larger, skip ahead to Large databases and point-in-time recovery.
Example PostgreSQL setup
All the examples in this guide use the following docker-compose.yml. The credentials live in a .env file (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB), which you should keep out of version control:
services: db: image: postgres:18 container_name: postgres env_file: .env volumes: - db_data:/var/lib/postgresql healthcheck: test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] interval: 10s timeout: 5s retries: 5
volumes: db_data:Your data lives in the db_data volume. If that volume is lost, so is your data, so keep your backups outside of it, and ideally on a different server entirely.
Volume path changed in PostgreSQL 18. The
postgres:18image stores data in/var/lib/postgresql/18/dockerand declares the volume at/var/lib/postgresql. Images for version 17 and earlier use/var/lib/postgresql/data. If you mount the old path on an 18 image, the container won’t start.
Need PostgreSQL on a server first? Check out our guide to deploying PostgreSQL on a VPS using Docker.
How to back up a PostgreSQL database in a Docker container
pg_dump exports a database from a consistent snapshot, so your application can keep reading and writing while the dump runs. Only schema changes, such as ALTER TABLE, have to wait until it’s done.
The simplest approach is to run it inside the container and write the output to the host. We recommend the custom format (-Fc):
docker exec postgres pg_dump -U myuser -d mydb -Fc > mydb.dump
# With Docker Composedocker compose exec -T db pg_dump -U myuser -d mydb -Fc > mydb.dumpThe custom format is compressed, restores in parallel with pg_restore -j, and lets you restore a single table from a full backup. If you’re on PostgreSQL 16 or later, add -Z zstd for smaller files in less time. If you’d rather have a plain SQL file you can read and edit, leave out -Fc.
There are three things to keep in mind when running pg_dump in Docker:
- Don’t allocate a TTY. Don’t use
-tor-itwhen you redirect output to a file. A TTY changes line endings and mixes error messages into the dump.docker compose execallocates one by default, so pass-T. - You don’t need a password. The official image trusts connections over the local socket inside the container.
- Match versions.
pg_dumpmust be the same major version as the server, or newer. Running it inside the database container takes care of this for you.
Back up roles and global objects
pg_dump doesn’t export roles, passwords, or tablespaces, because they belong to the cluster rather than a single database. Without them, a restore to a new server fails on any statement that references a role that doesn’t exist. Export them alongside each backup:
docker exec postgres pg_dumpall -U myuser --globals-only > globals.sqlAutomate backups with a sidecar container
The simplest way to automate backups is a second container that runs pg_dump in a loop:
services: # db: ... (as above)
backup: image: postgres:18 container_name: pg_backup environment: PGHOST: db PGUSER: ${POSTGRES_USER} PGPASSWORD: ${POSTGRES_PASSWORD} PGDATABASE: ${POSTGRES_DB} depends_on: db: condition: service_healthy volumes: - ./backups:/backups entrypoint: > bash -c 'while true; do file=/backups/db-$$(date +%F-%H%M%S).dump; if pg_dump -Fc -f "$$file.tmp"; then mv "$$file.tmp" "$$file"; else rm -f "$$file.tmp"; echo "Backup failed" >&2; fi; find /backups -name "db-*.dump" -mtime +7 -delete; sleep 86400; done'This service waits for the database health check to pass, writes a dump to ./backups every 24 hours, and deletes dumps older than 7 days. It writes to a .tmp file first, so a failed dump never looks like a valid backup. Note that $ is written as $$, because Docker Compose treats $ as variable interpolation.
The downside is that the schedule depends on when the container started. If you want backups at a fixed time, use a ready-made image or cron instead.
Use a ready-made backup image
If you’d rather not maintain the loop yourself, prodrigestivill/postgres-backup-local adds cron scheduling and daily, weekly and monthly retention:
services: # db: ... (as above)
backup: image: prodrigestivill/postgres-backup-local:18 depends_on: db: condition: service_healthy environment: POSTGRES_HOST: db POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} SCHEDULE: '@daily' BACKUP_KEEP_DAYS: 7 BACKUP_KEEP_WEEKS: 4 BACKUP_KEEP_MONTHS: 6 volumes: - ./backups:/backupsIt writes gzipped plain SQL files (.sql.gz), which you can restore as shown in Restore a plain SQL dump. Make sure the tag matches your PostgreSQL major version. At the time of writing, its latest build is from September 2025 (pg_dump 18.0).
Whichever image you choose, check that it’s still maintained. eeshugerman/postgres-backup-s3, which a lot of older guides recommend, was archived in 2025 and doesn’t have images for PostgreSQL 17 or later.
Schedule PostgreSQL backups with cron
Running cron on the host gives you a fixed schedule and room to add more steps after the dump. The script below handles the full job: dump, globals, retention, encrypted off-site upload, and a success ping. Save it as /opt/pg-backup/backup.sh:
#!/usr/bin/env bash# /opt/pg-backup/backup.sh - PostgreSQL backup for Dockerset -euo pipefail
CONTAINER="postgres"DB_USER="myuser"DB_NAME="mydb"BACKUP_DIR="/var/backups/postgres"DAILY_DAYS=7WEEKLY_DAYS=28MONTHLY_DAYS=365AGE_RECIPIENT="age1yourpublickey"REMOTE="remote:my-bucket/postgres"PING_URL="https://hc-ping.com/your-check-uuid"
# Stop if a previous run is still goingexec 9> /var/lock/pg-backup.lockflock -n 9 || { echo "Previous backup still running" >&2; exit 1; }
STAMP=$(date +%F_%H%M%S)DUMP="$BACKUP_DIR/$DB_NAME-$STAMP.dump"GLOBALS="$BACKUP_DIR/globals-$STAMP.sql"mkdir -p "$BACKUP_DIR/weekly" "$BACKUP_DIR/monthly" "$BACKUP_DIR/outbox"trap 'rm -f "$DUMP.tmp"' EXIT
# 1. Dump the database and the global objectsdocker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc -Z zstd > "$DUMP.tmp"mv "$DUMP.tmp" "$DUMP"docker exec "$CONTAINER" pg_dumpall -U "$DB_USER" --globals-only > "$GLOBALS"
# 2. Keep weekly (Sunday) and monthly (1st) copiesif [ "$(date +%u)" -eq 7 ]; then cp "$DUMP" "$BACKUP_DIR/weekly/"; fiif [ "$(date +%d)" = "01" ]; then cp "$DUMP" "$BACKUP_DIR/monthly/"; fi
# 3. Encrypt and upload off-sitefor file in "$DUMP" "$GLOBALS"; do age -r "$AGE_RECIPIENT" -o "$BACKUP_DIR/outbox/$(basename "$file").age" "$file"donerclone move "$BACKUP_DIR/outbox" "$REMOTE"
# 4. Prune local backupsfind "$BACKUP_DIR" -maxdepth 1 \( -name "*.dump" -o -name "globals-*.sql" \) \ -mtime +"$DAILY_DAYS" -deletefind "$BACKUP_DIR/weekly" -name "*.dump" -mtime +"$WEEKLY_DAYS" -deletefind "$BACKUP_DIR/monthly" -name "*.dump" -mtime +"$MONTHLY_DAYS" -delete
# 5. Report successcurl -fsS -m 10 --retry 3 "$PING_URL" > /dev/nullecho "Backup complete: $(basename "$DUMP")"Here’s what protects you from silent failures:
set -euo pipefailstops the script at the first failed command, including a failure inside a pipe. Withoutpipefail,pg_dump | gzip > filereports success whenpg_dumpfails, and you end up with an empty backup.- The dump goes to a
.tmpfile, and thetrapdeletes it on failure. flockstops a slow run from overlapping with the next one.- The ping only runs when every step before it succeeded.
Step 3 needs age and rclone, which we’ll set up in the next two sections. If you want to start without them, delete step 3 for now.
Make the script executable with sudo chmod 700 /opt/pg-backup/backup.sh, then add it to root’s crontab with sudo crontab -e:
0 2 * * * /opt/pg-backup/backup.sh >> /var/log/pg-backup.log 2>&1Cron runs with a minimal PATH, so use full paths if docker isn’t in /usr/bin. Cron also treats % as a new line, which is why the date calls live in the script rather than the crontab line.
Backup retention policies
Without a retention policy, backups will eventually fill your disk. The script uses a grandfather-father-son (GFS) policy:
- Daily backups for 7 days
- Weekly backups (Sunday) for 4 weeks
- Monthly backups (the 1st) for 12 months
Step 2 copies the weekly and monthly dumps into their own folders, and step 4 prunes each folder separately. The daily find uses -maxdepth 1, so it doesn’t touch the weekly and monthly copies.
Store backups off-site
A local backup protects you from a bad query, but it won’t help if the disk fails, the server is deleted, or the host is compromised. That’s why every backup should also go to an S3-compatible bucket, such as AWS S3, Cloudflare R2, Backblaze B2, or DigitalOcean Spaces.
The script uses rclone, which supports all of them. Run rclone config once to add a remote, then set REMOTE in the script. rclone move uploads each file and removes the local copy from the outbox once the upload succeeds.
Once that’s working, lock the bucket down:
- Set remote retention with a bucket lifecycle rule, for example “delete objects older than 90 days”.
- Give the server a key that can write but not delete, so an attacker on the server cannot remove old backups.
- For more protection, turn on object lock (S3, Backblaze B2) or a bucket lock rule (Cloudflare R2).
Encrypt off-site backups
A dump contains all of your data, including emails and password hashes, so encrypt every copy that leaves the server. The script keeps local copies unencrypted because they sit next to the live database anyway, and it keeps restores and restore tests simple.
The script uses age (sudo apt install age on Debian and Ubuntu). Generate a key pair on your own machine, not the server, and put only the public key in AGE_RECIPIENT:
age-keygen -o backup-key.txt
# Later, to decrypt a downloaded backupage -d -i backup-key.txt -o mydb.dump mydb.dump.ageStore the private key somewhere safe, like a password manager. If you lose it, you lose access to your off-site backups.
Restore a PostgreSQL dump in Docker
Restore a custom-format dump
docker exec postgres createdb -U myuser mydb_restoreddocker exec -i postgres pg_restore -U myuser -d mydb_restored --no-owner < mydb.dumpThe -i flag lets the container read the file from your terminal. --no-owner makes myuser the owner of all restored objects. If the dump grants privileges to roles that don’t exist on the target, add --no-privileges, or restore the global objects first.
A couple of other useful options:
--clean --if-existsdrops each object before it is recreated, to replace the data in an existing database.-j 4restores with 4 parallel jobs. It does not work from standard input, so first copy the file into the container withdocker cpand pass its path.
After a restore, run ANALYZE. pg_dump doesn’t include planner statistics by default, so queries can be slow until the tables have been analysed:
docker exec postgres psql -U myuser -d mydb_restored -c "ANALYZE;"Restore a plain SQL dump
docker exec -i postgres psql -U myuser -d mydb_restored -v ON_ERROR_STOP=1 < mydb.sql
# From a gzipped dumpgunzip -c mydb.sql.gz | docker exec -i postgres psql -U myuser -d mydb_restored -v ON_ERROR_STOP=1ON_ERROR_STOP=1 stops the restore at the first error. Without it, you can end up with a partial restore that looks complete. A plain SQL dump doesn’t overwrite existing tables, so always restore into an empty database.
Restore to a new server
On a fresh server, restore the global objects first, then the database:
docker exec -i postgres psql -U myuser -d postgres < globals.sqldocker exec postgres createdb -U myuser mydbdocker exec -i postgres pg_restore -U myuser -d mydb < mydb.dumpYou’ll see the error role "myuser" already exists. That’s expected, because the image creates that role when it starts.
Test your restores automatically
Backups can fail without anyone noticing: a full disk, a changed password, an expired bucket key. The only real proof that a backup works is restoring it. Save this as /opt/pg-backup/restore-test.sh and run it once a week from cron:
#!/usr/bin/env bashset -euo pipefail
LATEST=$(ls -t /var/backups/postgres/*.dump | head -n 1)
docker run -d --name pg-restore-test -e POSTGRES_PASSWORD=test postgres:18 > /dev/nulltrap 'docker rm -f pg-restore-test > /dev/null' EXIT
until docker exec pg-restore-test pg_isready -h 127.0.0.1 -U postgres > /dev/null 2>&1; do sleep 1done
docker exec pg-restore-test createdb -U postgres restore_testdocker exec -i pg-restore-test pg_restore -U postgres -d restore_test \ --no-owner --no-privileges --exit-on-error < "$LATEST"
# Change "users" to a table that always has rowsROWS=$(docker exec pg-restore-test psql -U postgres -d restore_test -tAc "SELECT count(*) FROM users")if [ "$ROWS" -eq 0 ]; then echo "Restore test failed: users table is empty" >&2 exit 1fi
echo "Restore test passed: $ROWS rows in users from $(basename "$LATEST")"The script restores the newest dump into a temporary container, checks that a known table has rows, and then removes the container. It checks readiness over TCP (-h 127.0.0.1) because, on first start, the image’s setup server only listens on the local socket.
Monitor your backup jobs
A cron job that fails at 02:00 won’t tell anyone, so you need something watching it.
Report each run. The script pings a dead man’s switch service, such as Healthchecks.io or Cronitor. If a ping doesn’t arrive on time, the service alerts you. Use a separate check for the restore test.
Check the age of the newest backup. This catches problems the ping can’t, like a deleted crontab or a stopped sidecar container:
if [ -z "$(find /var/backups/postgres -maxdepth 1 -name '*.dump' -mmin -1500)" ]; then echo "No PostgreSQL backup in the last 25 hours" >&2 exit 1fiIf you want alerts when a backup container stops, check out our guide to monitoring Docker containers with real-time alerts.
Large databases and point-in-time recovery
On a large database, pg_dump and especially the restore can take hours, and a daily dump means you could lose up to 24 hours of data. Two pg_dump options will help for a while: the directory format with parallel jobs (-Fd -j 4), and --exclude-table-data for tables you can rebuild, such as caches.
Beyond that, it’s time to switch to physical backups with WAL archiving. With a base backup and the write-ahead log, you can restore to any moment, for example the second before a bad DELETE. This is point-in-time recovery (PITR). We recommend pgBackRest or WAL-G to manage it rather than a custom script.
Production backup checklist
Before you call your backups production-ready, check that:
- Backups run on a schedule and report each run to a monitor.
- Every backup has an encrypted copy off the server, in a bucket the server cannot delete from.
- Roles and globals are backed up with each dump.
- A restore test runs every week.
Automate PostgreSQL backups with Serversinc
The scripts in this guide work, but you have to maintain them on every server. Serversinc handles all of this from one dashboard, for databases on servers you own:
- Back up full PostgreSQL instances or selected databases, including roles and other globals, and named Docker volumes.
- Upload directly from your server to your own S3-compatible bucket, on a cron schedule, with retention by age, count, or both.
- Restore to the same server or another server, with an engine and version check before the restore starts.
Check out the databases and backups docs to get started.
FAQ
How do I automate PostgreSQL backups in Docker?
Run pg_dump on a schedule: with cron on the host, a sidecar container, or a ready-made image such as prodrigestivill/postgres-backup-local. Then add retention, an encrypted off-site copy, and monitoring, as in the cron backup script.
Does pg_dump lock the database?
No. pg_dump reads from a consistent snapshot and holds ACCESS SHARE locks. Reads and writes continue. Only statements that need an exclusive lock, such as ALTER TABLE or DROP TABLE, wait until the dump is done.
How often should I back up PostgreSQL?
Back up at least as often as the amount of data you can afford to lose. For most applications, a daily dump is the minimum. If you can’t afford to lose an hour of data, use hourly dumps or WAL archiving.
Can I back up the Docker volume instead of using pg_dump?
Only if the database container is stopped. Copying the data files from a running database doesn’t give you a consistent backup. Volume copies also only restore to the same PostgreSQL major version.
Can I use this setup for MySQL or MariaDB?
The approach is the same: a dump tool, cron, retention, age and rclone. Only the dump and restore commands change. Our MySQL and MariaDB backup guide has the full script for both.