pgProofGet early access

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

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