Blog

Automated PostgreSQL Backups in Docker with pg_dump (2026 Guide)

Automate PostgreSQL backups in Docker with pg_dump: cron schedules, retention, encrypted off-site copies, restores, monitoring and a complete backup script.

Max Diamond 16 min read

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:

Terminal window
docker exec postgres pg_dump -U myuser -d mydb -Fc > mydb.dump

Restore it into an empty database:

Terminal window
docker exec postgres createdb -U myuser mydb_restored
docker exec -i postgres pg_restore -U myuser -d mydb_restored --no-owner < mydb.dump

The 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)
OutputSQL or an archive of schema and dataA copy of the data directory files
Restore to a newer major versionYesNo
Point-in-time recoveryNo, only to the time of the dumpYes, with WAL archiving
Speed on large databasesSlow to dump and restoreFast
SetupOne commandMore 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:18 image stores data in /var/lib/postgresql/18/docker and 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):

Terminal window
docker exec postgres pg_dump -U myuser -d mydb -Fc > mydb.dump
# With Docker Compose
docker compose exec -T db pg_dump -U myuser -d mydb -Fc > mydb.dump

The 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 -t or -it when you redirect output to a file. A TTY changes line endings and mixes error messages into the dump. docker compose exec allocates 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_dump must 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:

Terminal window
docker exec postgres pg_dumpall -U myuser --globals-only > globals.sql

Automate 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:/backups

It 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 Docker
set -euo pipefail
CONTAINER="postgres"
DB_USER="myuser"
DB_NAME="mydb"
BACKUP_DIR="/var/backups/postgres"
DAILY_DAYS=7
WEEKLY_DAYS=28
MONTHLY_DAYS=365
AGE_RECIPIENT="age1yourpublickey"
REMOTE="remote:my-bucket/postgres"
PING_URL="https://hc-ping.com/your-check-uuid"
# Stop if a previous run is still going
exec 9> /var/lock/pg-backup.lock
flock -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 objects
docker 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) copies
if [ "$(date +%u)" -eq 7 ]; then cp "$DUMP" "$BACKUP_DIR/weekly/"; fi
if [ "$(date +%d)" = "01" ]; then cp "$DUMP" "$BACKUP_DIR/monthly/"; fi
# 3. Encrypt and upload off-site
for file in "$DUMP" "$GLOBALS"; do
age -r "$AGE_RECIPIENT" -o "$BACKUP_DIR/outbox/$(basename "$file").age" "$file"
done
rclone move "$BACKUP_DIR/outbox" "$REMOTE"
# 4. Prune local backups
find "$BACKUP_DIR" -maxdepth 1 \( -name "*.dump" -o -name "globals-*.sql" \) \
-mtime +"$DAILY_DAYS" -delete
find "$BACKUP_DIR/weekly" -name "*.dump" -mtime +"$WEEKLY_DAYS" -delete
find "$BACKUP_DIR/monthly" -name "*.dump" -mtime +"$MONTHLY_DAYS" -delete
# 5. Report success
curl -fsS -m 10 --retry 3 "$PING_URL" > /dev/null
echo "Backup complete: $(basename "$DUMP")"

Here’s what protects you from silent failures:

  • set -euo pipefail stops the script at the first failed command, including a failure inside a pipe. Without pipefail, pg_dump | gzip > file reports success when pg_dump fails, and you end up with an empty backup.
  • The dump goes to a .tmp file, and the trap deletes it on failure.
  • flock stops 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>&1

Cron 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:

Terminal window
age-keygen -o backup-key.txt
# Later, to decrypt a downloaded backup
age -d -i backup-key.txt -o mydb.dump mydb.dump.age

Store 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

Terminal window
docker exec postgres createdb -U myuser mydb_restored
docker exec -i postgres pg_restore -U myuser -d mydb_restored --no-owner < mydb.dump

The -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-exists drops each object before it is recreated, to replace the data in an existing database.
  • -j 4 restores with 4 parallel jobs. It does not work from standard input, so first copy the file into the container with docker cp and 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:

Terminal window
docker exec postgres psql -U myuser -d mydb_restored -c "ANALYZE;"

Restore a plain SQL dump

Terminal window
docker exec -i postgres psql -U myuser -d mydb_restored -v ON_ERROR_STOP=1 < mydb.sql
# From a gzipped dump
gunzip -c mydb.sql.gz | docker exec -i postgres psql -U myuser -d mydb_restored -v ON_ERROR_STOP=1

ON_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:

Terminal window
docker exec -i postgres psql -U myuser -d postgres < globals.sql
docker exec postgres createdb -U myuser mydb
docker exec -i postgres pg_restore -U myuser -d mydb < mydb.dump

You’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 bash
set -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/null
trap '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 1
done
docker exec pg-restore-test createdb -U postgres restore_test
docker 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 rows
ROWS=$(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 1
fi
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:

Terminal window
if [ -z "$(find /var/backups/postgres -maxdepth 1 -name '*.dump' -mmin -1500)" ]; then
echo "No PostgreSQL backup in the last 25 hours" >&2
exit 1
fi

If 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.

Run your apps on servers you own.

Connect a server you already run, or provision one on your cloud account. £5 a month covers the first three.