Sign inSign up

cc0funobit/pg-backup-s3

By cc0funobit

•Updated about 7 hours ago

Scheduled PostgreSQL backups with rotation, synced to multiple S3 buckets with optional encryption.

Image
Integration & delivery
Databases & storage
1

3.2K

cc0funobit/pg-backup-s3 repository overview

⁠pg-backup-s3

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⁠

⁠Why

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.

⁠Features

  • Scheduled Postgres dumps with the familiar last/daily/weekly/monthly layout and BACKUP_KEEP_* rotation
  • Fan-out to multiple S3 destinations, each with its own credentials, region and endpoint (AWS S3, Backblaze B2, MinIO, ...)
  • Per-destination sync schedule (S3_SCHEDULE[_N]): keep a hot bucket in sync after every dump while a cold/DR bucket only receives a weekly push
  • Per-destination encryption (off by default) with GPG or OpenSSL, so the off-site copy is protected by a passphrase only you hold
  • Server-side encryption via --sse aws:kms (turned off automatically for non-AWS endpoints, still overridable)
  • File-based secrets (*_FILE) for POSTGRES_*, the AWS secret key and the encryption passphrase
  • Webhooks around each backup, and pg_dumpall cluster dumps
  • Never uploads a half-written dump, and never wipes the remote copy from an empty source
  • Works as a non-root user (--user)
  • linux/amd64 and linux/arm64, rebuilt, tested and vulnerability-scanned twice a day

⁠Tags

  • 14, 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.

⁠Usage

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

⁠Configuration

⁠PostgreSQL

These are the postgres-backup-local variables with the same defaults. An empty value or **None** means "unset".

VariableDefaultDescription
POSTGRES_HOST(required)Server host. Falls back to POSTGRES_PORT_5432_TCP_ADDR / _PORT (legacy links)
POSTGRES_PORT5432Server 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-Z1Extra pg_dump / pg_dumpall flags
POSTGRES_CLUSTERFALSETRUE runs pg_dumpall instead of pg_dump; POSTGRES_DB then names the maintenance database
SCHEDULE@dailyDump 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_STARTTRUECheck the configuration at start and fail fast
BACKUP_ON_STARTFALSEAlso dump once at container start
BACKUP_DIR/backupsWhere the dumps and the rotation folders live; also the directory synced to S3
BACKUP_SUFFIX.sql.gzDump file extension
BACKUP_LATEST_TYPEsymlinksymlink or hardlink creates <db>-latest<suffix> in each folder; anything else creates none
BACKUP_KEEP_MINS / _DAYS / _WEEKS / _MONTHS1440 / 7 / 4 / 6Local rotation
HEALTHCHECK_PORT8080go-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.

⁠Webhooks

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.

VariableCalled on
WEBHOOK_URLpost-backup and error
WEBHOOK_PRE_BACKUP_URLpre-backup
WEBHOOK_POST_BACKUP_URLpost-backup
WEBHOOK_ERROR_URLerror
WEBHOOK_EXTRA_ARGSextra curl arguments for every call
⁠S3 destinations

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)DefaultDescription
S3_BUCKET / S3_BUCKET_N(required)Target bucket. Presence of S3_BUCKET_N defines destination N
S3_PREFIX / S3_PREFIX_Npg-backupsKey prefix within the bucket
S3_REGION / S3_REGION_NAWS_DEFAULT_REGIONRegion for that destination
S3_ENDPOINT / S3_ENDPOINT_N(none = AWS)Custom S3 endpoint URL (Backblaze B2, MinIO, ...)
S3_SSE / S3_SSE_Naws:kms / noneServer-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_NtrueMirror 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)
⁠Sync scheduling (per destination)

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)DefaultDescription
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 / _Ntrue without a schedule, else falseSync this destination right after each dump. Set true alongside a schedule to do both
S3_SYNC_ON_START / _NfalseA scheduled destination also syncs once at container start

Global knobs:

VariableDefaultDescription
S3_SYNC_SCHEDULERtrueSet false to skip starting the per-destination schedulers
S3_SYNC_LOCK_WAIT1800Seconds a sync waits for another sync to finish before skipping its run
S3_SYNC_BACKUP_WAIT300Seconds a scheduled sync waits for an in-flight dump to finish
S3_SYNC_STATE_DIR/tmp/pg-backup-s3Where 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:

  • A scheduled sync pushes whatever is in 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.
  • Only one destination uploads at a time, and a scheduled sync waits for a running dump.
  • Cron specs use the container's timezone, so set TZ if the exact hour matters. An unparseable spec fails the container at startup instead of silently never syncing.
  • Scheduling needs the image's default command (/usr/local/bin/pg-backup-s3-entrypoint). If you override command: or entrypoint:, the schedulers never start.
  • Push on demand, regardless of schedules: 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 (per destination)

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)DefaultDescription
ENCRYPTION_ENABLED / _NfalseEncrypt this destination's copy
ENCRYPTION_METHOD / _Ngpggpg (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.

⁠File-based secrets

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.

⁠How it works

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:

  • The post-backup hook that /backup.sh runs after each dump. It syncs every destination that has no schedule.
  • One 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.
  • A flock mutex allows only one sync at a time. A scheduled sync waits up to S3_SYNC_BACKUP_WAIT for a running dump.
  • Each destination runs in its own subshell, so credentials and flags never leak between destinations.
  • If any destination fails, the run exits non-zero and /backup.sh fires its error hooks.

⁠Restoring

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.

⁠Running as a non-root user

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.

⁠Notes

  • For AWS destinations, the IAM identity needs 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.
  • Store the encryption passphrase somewhere safe and separate from the backups. Without it, encrypted backups cannot be restored.
  • Backing up MongoDB instead? See cc0funobit/mongo-backup-s3⁠.

⁠License

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

Tag summary

Content type

Image

Digest

sha256:c86f9cd21…

Size

118.8 MB

Last updated

about 7 hours ago

docker pull cc0funobit/pg-backup-s3