Scheduled PostgreSQL backups with rotation, synced to multiple S3 buckets with optional encryption.
3.2K
Scheduled PostgreSQL backups that are automatically synced to one or more S3 destinations, each on its own schedule, with optional password-based encryption.
The image runs pg_dump on a cron schedule, keeps a rotated local copy (last/, daily/, weekly/, monthly/) and ships it off-site with the AWS CLI. It is an environment-compatible replacement for prodrigestivill/postgres-backup-local: same variables, defaults, file names, folder layout, hooks, webhooks, healthcheck and /backup.sh. Existing configurations keep working.
It is no longer built on top of that image, because its images are not rebuilt for security fixes. It ships a port of the project's scripts (MIT) on a fresh ubuntu:24.04 base with only the PostgreSQL client from the official PGDG repository. The MongoDB sibling is cc0funobit/mongo-backup-s3; both share the same S3 layer.
Source: github.com/CodingCogs-OSS/pg-backup-s3
A local dump is not a backup until a copy lives somewhere else. This image gives you durable off-site copies of databases that may contain PII. You can fan the same backups out to several buckets (even across providers), encrypt the off-site copies with a passphrase you control, and give each bucket its own sync cadence instead of pushing to all of them after every dump.
last/daily/weekly/monthly layout and BACKUP_KEEP_* rotationS3_SCHEDULE[_N]): keep a hot bucket in sync after every dump while a cold/DR bucket only receives a weekly push--sse aws:kms (turned off automatically for non-AWS endpoints, still overridable)*_FILE) for POSTGRES_*, the AWS secret key and the encryption passphrasepg_dumpall cluster dumps--user)linux/amd64 and linux/arm64, rebuilt, tested and vulnerability-scanned twice a day14, 15, 16, 17, 18: built against that PostgreSQL major. Pin the tag that matches your server, because the pg_dump major version should match the server's major version.latest: currently 18. It used to be 17; if you used latest with a PostgreSQL 17 server, switch to :17.<version>-<UTC build time> (for example 17-20261030T031700Z): immutable tag for one exact build. The moving tag (17) always points at the newest build for that version. latest has no timestamp tag.A single destination:
services:
pg-backup:
image: cc0funobit/pg-backup-s3:18
environment:
# PostgreSQL
POSTGRES_HOST: db
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
SCHEDULE: "@daily"
# S3 sync
S3_BUCKET: my-backup-bucket
S3_PREFIX: pg-backups
AWS_ACCESS_KEY_ID: ...
AWS_SECRET_ACCESS_KEY: ...
AWS_DEFAULT_REGION: eu-central-1
volumes:
- pg-backups:/backups
volumes:
pg-backups:
Two destinations, file-based secrets, encryption on the primary one and a weekly schedule on the second:
services:
pg-backup:
image: cc0funobit/pg-backup-s3:18
environment:
# --- PostgreSQL ---
POSTGRES_HOST: db
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
SCHEDULE: "@daily"
# --- Destination #1: AWS S3, encrypted, synced after every dump ---
S3_BUCKET: primary-bucket
S3_PREFIX: pg-backups
AWS_DEFAULT_REGION: eu-central-1
AWS_ACCESS_KEY_ID: AKIA...
AWS_SECRET_ACCESS_KEY_FILE: /run/secrets/aws_secret_1
ENCRYPTION_ENABLED: "true"
ENCRYPTION_PASSPHRASE_FILE: /run/secrets/backup_pass
# --- Destination #2: Backblaze B2, own keys + endpoint, weekly ---
S3_BUCKET_2: dr-bucket
S3_PREFIX_2: pg-backups
S3_REGION_2: us-west-002
S3_ENDPOINT_2: https://s3.us-west-002.backblazeb2.com
S3_SCHEDULE_2: "0 4 * * 0"
AWS_ACCESS_KEY_ID_2: 0021...
AWS_SECRET_ACCESS_KEY_2_FILE: /run/secrets/aws_secret_2
# S3_SSE_2 left unset -> none, because an endpoint is set
secrets: [pg_password, aws_secret_1, aws_secret_2, backup_pass]
secrets:
pg_password: { file: ./secrets/pg_password }
aws_secret_1: { file: ./secrets/aws_secret_1 }
aws_secret_2: { file: ./secrets/aws_secret_2 }
backup_pass: { file: ./secrets/backup_pass }
Run a backup on demand: docker compose exec pg-backup /backup.sh
These are the postgres-backup-local variables with the same defaults. An empty value or **None** means "unset".
| Variable | Default | Description |
|---|---|---|
POSTGRES_HOST | (required) | Server host. Falls back to POSTGRES_PORT_5432_TCP_ADDR / _PORT (legacy links) |
POSTGRES_PORT | 5432 | Server port |
POSTGRES_DB (+_FILE) | (required) | Comma or space separated databases, one dump each. The file lists one per line |
POSTGRES_USER (+_FILE) | (required) | User name |
POSTGRES_PASSWORD (+_FILE) | (one of) | Password. Given to pg_dump through PGPASSWORD, never on the command line |
POSTGRES_PASSFILE_STORE | (none) | Path of a libpq passfile, used when neither password variable is set |
POSTGRES_EXTRA_OPTS | -Z1 | Extra pg_dump / pg_dumpall flags |
POSTGRES_CLUSTER | FALSE | TRUE runs pg_dumpall instead of pg_dump; POSTGRES_DB then names the maintenance database |
SCHEDULE | @daily | Dump schedule: 5 (or 6, seconds first) cron fields or a descriptor such as @every 6h. An invalid value stops the container at startup |
VALIDATE_ON_START | TRUE | Check the configuration at start and fail fast |
BACKUP_ON_START | FALSE | Also dump once at container start |
BACKUP_DIR | /backups | Where the dumps and the rotation folders live; also the directory synced to S3 |
BACKUP_SUFFIX | .sql.gz | Dump file extension |
BACKUP_LATEST_TYPE | symlink | symlink or hardlink creates <db>-latest<suffix> in each folder; anything else creates none |
BACKUP_KEEP_MINS / _DAYS / _WEEKS / _MONTHS | 1440 / 7 / 4 / 6 | Local rotation |
HEALTHCHECK_PORT | 8080 | go-cron status endpoint used by the healthcheck. It is unauthenticated, so don't publish it |
TZ | (UTC) | Time zone for cron schedules and file names |
VALIDATE_ON_START, BACKUP_ON_START and POSTGRES_CLUSTER accept 1, true, yes or on in any case. Dumps are written as <file>.partial and renamed when complete, and .partial files are never uploaded.
Each call is an HTTP POST with the JSON body {"status": "<action>"}. The URL is handed to curl on stdin and never logged, so tokens inside it stay out of ps and the container logs.
| Variable | Called on |
|---|---|
WEBHOOK_URL | post-backup and error |
WEBHOOK_PRE_BACKUP_URL | pre-backup |
WEBHOOK_POST_BACKUP_URL | post-backup |
WEBHOOK_ERROR_URL | error |
WEBHOOK_EXTRA_ARGS | extra curl arguments for every call |
Destination #1 uses the unsuffixed variables. Add more destinations by appending a numeric suffix _2, _3, ... to the same variable names. Each destination is independent: it has its own bucket, credentials, region, endpoint, SSE, schedule and encryption settings. Any per-destination value left empty falls back to the unsuffixed value, so the unsuffixed variables work as shared defaults.
Variable (and _N form) | Default | Description |
|---|---|---|
S3_BUCKET / S3_BUCKET_N | (required) | Target bucket. Presence of S3_BUCKET_N defines destination N |
S3_PREFIX / S3_PREFIX_N | pg-backups | Key prefix within the bucket |
S3_REGION / S3_REGION_N | AWS_DEFAULT_REGION | Region for that destination |
S3_ENDPOINT / S3_ENDPOINT_N | (none = AWS) | Custom S3 endpoint URL (Backblaze B2, MinIO, ...) |
S3_SSE / S3_SSE_N | aws:kms / none | Server-side encryption. Defaults to aws:kms on AWS and to none when an endpoint is set. Set to "" to force off |
S3_DELETE / S3_DELETE_N | true | Mirror local rotation by deleting remote objects that no longer exist locally. Set false for an append-only remote copy |
AWS_ACCESS_KEY_ID / _N | (required) | Access key for that destination |
AWS_SECRET_ACCESS_KEY / _N | (required) | Secret key for that destination (supports _FILE) |
By default every destination is synced right after each dump. Set S3_SCHEDULE[_N] on a destination to take it out of that path and give it its own cadence: the image then runs a small cron scheduler for it and syncs it on that schedule only.
Variable (and _N form) | Default | Description |
|---|---|---|
S3_SCHEDULE / _N | (none = sync after every backup) | Cron spec for this destination's sync. Same syntax as SCHEDULE. Use none to opt a destination out of an inherited unsuffixed S3_SCHEDULE |
S3_SYNC_ON_BACKUP / _N | true without a schedule, else false | Sync this destination right after each dump. Set true alongside a schedule to do both |
S3_SYNC_ON_START / _N | false | A scheduled destination also syncs once at container start |
Global knobs:
| Variable | Default | Description |
|---|---|---|
S3_SYNC_SCHEDULER | true | Set false to skip starting the per-destination schedulers |
S3_SYNC_LOCK_WAIT | 1800 | Seconds a sync waits for another sync to finish before skipping its run |
S3_SYNC_BACKUP_WAIT | 300 | Seconds a scheduled sync waits for an in-flight dump to finish |
S3_SYNC_STATE_DIR | /tmp/pg-backup-s3 | Where the sync lock and the "dump in progress" marker live |
Example: hot bucket after every dump, DR bucket every Sunday at 04:00, cold archive daily plus once at start.
environment:
TZ: Europe/Berlin # cron specs use the container timezone
SCHEDULE: "@daily"
S3_BUCKET: hot-bucket # no S3_SCHEDULE -> synced after every dump
S3_BUCKET_2: dr-bucket
S3_SCHEDULE_2: "0 4 * * 0" # weekly, decoupled from the dump schedule
S3_BUCKET_3: cold-bucket
S3_SCHEDULE_3: "@daily"
S3_SYNC_ON_START_3: "true"
Notes:
BACKUP_DIR at that moment. A destination on a weekly schedule therefore ships the dumps that survived local rotation (BACKUP_KEEP_*). Keep local retention long enough to cover the gap between syncs.TZ if the exact hour matters. An unparseable spec fails the container at startup instead of silently never syncing./usr/local/bin/pg-backup-s3-entrypoint). If you override command: or entrypoint:, the schedulers never start.docker compose exec pg-backup s3-sync --all (or --dest 2 for one bucket). s3-sync --list shows the destinations and how each is triggered.Encryption is off by default. When enabled for a destination, the copy uploaded there is encrypted with a passphrase. The local dumps stay plain .sql.gz.
Variable (and _N form) | Default | Description |
|---|---|---|
ENCRYPTION_ENABLED / _N | false | Encrypt this destination's copy |
ENCRYPTION_METHOD / _N | gpg | gpg (AES-256) or openssl (AES-256-CBC) |
ENCRYPTION_PASSPHRASE / _N | (none) | Passphrase, required when enabled (supports _FILE) |
Encrypted objects keep the original key layout with an added extension: .sql.gz.gpg for GPG or .sql.gz.enc for OpenSSL.
Append _FILE to a variable to read its value from a file (Docker, Compose and Swarm secrets). Supported: POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB, AWS_SECRET_ACCESS_KEY (and AWS_SECRET_ACCESS_KEY_N), ENCRYPTION_PASSPHRASE (and ENCRYPTION_PASSPHRASE_N).
For AWS_* and ENCRYPTION_* the plain variable wins when both it and its _FILE companion are set. For POSTGRES_* the _FILE value wins, as in postgres-backup-local.
All S3 logic lives in one script, /usr/local/bin/s3-sync (run docker compose exec pg-backup s3-sync --help for its full reference). It is driven from two places:
post-backup hook that /backup.sh runs after each dump. It syncs every destination that has no schedule.go-cron scheduler per destination that has an S3_SCHEDULE[_N], started by the image's default command. Each one runs s3-sync --dest <id> on its own cadence.For an unencrypted destination the sync mirrors the local layout:
aws s3 sync /backups "s3://${S3_BUCKET}/${S3_PREFIX:-pg-backups}" \
--sse aws:kms \
--delete \
--only-show-errors
With --delete (the default) the remote follows local rotation. For an encrypted destination the current backup tree is encrypted file by file into a temporary staging directory, which is synced with the same flags and removed afterwards, so no stale ciphertext is left behind.
Safeguards:
.partial files are never uploaded, so a half-written dump is never shipped.s3-sync skips a destination when BACKUP_DIR holds no files, so a --delete run can never wipe the remote copy from an empty source.flock mutex allows only one sync at a time. A scheduled sync waits up to S3_SYNC_BACKUP_WAIT for a running dump./backup.sh fires its error hooks.Dumps are named <db>-<timestamp>.sql.gz under last/, daily/, weekly/ and monthly/, both locally and in S3.
# 1. find and download the dump
aws s3 ls s3://my-backup-bucket/pg-backups/daily/
aws s3 cp s3://my-backup-bucket/pg-backups/daily/app-20261030-000000.sql.gz .
# 2. restore into a fresh, empty database
gunzip -c app-20261030-000000.sql.gz | psql -h db -U app -d app
Or straight from S3 without writing to disk:
aws s3 cp s3://my-backup-bucket/pg-backups/last/app-latest.sql.gz - | gunzip | psql -h db -U app -d app
If the destination had ENCRYPTION_ENABLED=true, decrypt first.
GPG (.sql.gz.gpg):
gpg --batch --pinentry-mode loopback --passphrase-file ./pass \
-o app-20261030-000000.sql.gz -d app-20261030-000000.sql.gz.gpg
gunzip app-20261030-000000.sql.gz
OpenSSL (.sql.gz.enc):
openssl enc -d -aes-256-cbc -pbkdf2 -pass file:./pass \
-in app-20261030-000000.sql.gz.enc -out app-20261030-000000.sql.gz
gunzip app-20261030-000000.sql.gz
For a cluster dump (POSTGRES_CLUSTER=TRUE, made with pg_dumpall), restore it the same way into a fresh server.
The default user is root, for compatibility with existing setups. The image also works with --user <uid>:<gid> (Compose: user: "10001:10001"). The image contains a postgres user (uid/gid 999), so the user: postgres:postgres that postgres-backup-local recommends keeps working.
/backups must be writable by that uid. Docker resets the owner of an empty named volume to root on every mount, so create a folder in it too:
docker run --rm -v pg-backups:/backups cc0funobit/pg-backup-s3:18 \
sh -c 'mkdir -p /backups/last && chown -R 10001:10001 /backups'
Keep HEALTHCHECK_PORT above 1024. If /backups is not writable, the container stops at start with a message naming the uid.
s3:PutObject and s3:ListBucket (and s3:DeleteObject, since destinations sync with --delete by default) on the target bucket, plus kms:GenerateDataKey on the KMS key when SSE is on. For restores it also needs s3:GetObject and kms:Decrypt.cc0funobit/mongo-backup-s3.This project's scripts and Dockerfiles are released under the MIT License. The PostgreSQL image's init.sh, env.sh, backup.sh and webhook hook are a port of prodrigestivill/docker-postgres-backup-local (MIT, Copyright (c) 2023 Pau Rodriguez-Estivill and Johannes Schickling); the notice is in the image at /usr/share/doc/pg-backup-s3/NOTICE.postgres-backup-local. The published image also bundles third-party software with its own terms: the PostgreSQL client (PGDG), go-cron, the AWS CLI and Ubuntu packages. Refer to their respective licenses for those components.
Maintained by the team at codingcogs.org.
Content type
Image
Digest
sha256:c86f9cd21…
Size
118.8 MB
Last updated
about 7 hours ago
docker pull cc0funobit/pg-backup-s3