Skip to content

About

Scheduled PostgreSQL pg_dump backups with restic encryption, S3 storage, retention, and Docker Compose

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

postgres-restic-backup

Scheduled PostgreSQL backups in one container: consistent online pg_dump archives, encrypted restic storage, S3 uploads, and daily/weekly/monthly retention. Based on the design of sqlite-restic-backup.

Image: ghcr.io/txchen/postgres-restic-backup. CI tests Linux amd64 and arm64 before publishing. The application and database stay online. No Docker socket, database directory mount, host cron, SSH, or external job runner is needed.

Quick start

Add the service from compose.yaml to your application's Compose file so it can reach the database over the same network. Configure PGHOST, PGDATABASE, PGUSER, credentials, and a dedicated restic repository. The example uses process environment variables for secrets; do not put production values in Git or print rendered Compose output.

docker compose config --quiet
docker compose pull backup
# Initialize only a NEW dedicated restic repository; never reinitialize an existing one.
docker compose run --rm backup init
docker compose run --rm backup backup
docker compose run --rm backup check
docker compose run --rm backup snapshots
docker compose up -d backup

Pre-create the S3 bucket. SeaweedFS uses the same S3 pattern: s3:https://s3.example.com/backups/my-service. Use the actual S3 endpoint, not its administration URL. Verify TLS and perform an upload/restore drill against your own endpoint. A local restic destination also works if mounted into the container.

latest follows master. For reproducible deployments pin a published sha-<full-commit-id>, release tag, or image digest. GHCR package visibility is separate from GitHub repository visibility; private packages require registry authentication.

How it works

PostgreSQL over the Compose network
  -> pg_dump consistent online snapshot
  -> /staging/database.dump.next (custom archive, compression disabled)
  -> pg_restore --list parses archive catalog
  -> atomic rename to /staging/database.dump
  -> restic encryption, compression, deduplication and upload
  -> scoped retention and prune
  -> persistent success timestamp

The archive is uncompressed so restic can deduplicate the dump before compressing it. Archive catalog parsing catches some invalid output but does not prove full recoverability. Periodically restore into an isolated PostgreSQL instance and verify application data.

BusyBox cron runs inside the container. GitHub Actions only builds, tests, and publishes the image. Startup does not perform a backup or catch up missed schedules; run a manual backup before starting the scheduler. Update Compose and recreate the service to change the schedule. Use UTC to avoid daylight-saving skips/repeats.

Configuration

Variable Default or purpose
BACKUP_NAME Required stable identifier, using letters, digits, dots, underscores or hyphens
PGHOST Required database host, e.g. postgres on the shared Compose network
PGPORT 5432
PGDATABASE Required single database name; connection strings are rejected
PGUSER Required user with permission to dump the complete database
PGPASSWORD Database password; alternatively mount a mode-0600 PGPASSFILE
PGSSLMODE verify-full; example explicitly uses disable only for a trusted local Docker network
PGSSLROOTCERT Mount the trusted server CA and set its container path for remote verified TLS
PGCONNECT_TIMEOUT 10 seconds
TZ UTC
CRON_SCHEDULE 0 3 * * *, five numeric cron fields
KEEP_DAILY / KEEP_WEEKLY / KEEP_MONTHLY 7 / 4 / 12; all three cannot be zero
BACKUP_TIMEOUT_SECONDS 1800, including dump, upload and retention
MAX_BACKUP_AGE_SECONDS 172800 (48 hours); must exceed the longest scheduled interval
RESTIC_REPOSITORY Required dedicated restic repository, typically an S3 bucket/prefix
RESTIC_PASSWORD Encryption password; restic also supports RESTIC_PASSWORD_FILE
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY S3 credentials when using S3

For a weekly Sunday backup set CRON_SCHEDULE: "0 3 * * 0" and, for example, MAX_BACKUP_AGE_SECONDS: "691200" (8 days). Retention does not schedule backups: weekly execution cannot create daily recovery points.

Give every database its own backup service, stable BACKUP_NAME, staging directory and repository prefix. Only snapshots matching the host, fixed archive path, and postgres-backup tag are eligible for retention. Retention tiers combine with OR. Do not apply object-age deletion rules inside a restic repository.

PostgreSQL scope and access

The image contains PostgreSQL 18.6 client tools and restic 0.19.1. CI exercises PostgreSQL 18. PostgreSQL 18 pg_dump can read older supported servers, but cannot dump a newer major version; do not assume restoring into an older major version works. Use matching target versions and install any required extensions before restoring. Other server versions and custom extensions need their own restore validation.

Use an application database owner or a suitably authorized backup role; the backup container does not inherently require a PostgreSQL superuser. A read-only role needs access to every relevant table, sequence, large object and schema, and row-level security may need special handling. Insufficient permissions must fail the dump, not produce a silently partial backup.

This backs up one database only. pg_dump does not include cluster roles or tablespaces, physical WAL archives, or point-in-time recovery. Keep role creation and credentials in your recovery procedure. The restore example recreates ownership as the target user and deliberately omits original ACLs; restore original roles and ACLs separately if your application relies on multiple roles.

Mattermost attachments, application configuration, and plugin files are not database contents. This image does not back them up or coordinate an application-wide consistent snapshot. A complete Mattermost recovery set needs these files as well, captured with writes paused or another validated consistency strategy.

Files and secrets

  • Bind ./backup-staging:/staging and ./backup-cache:/root/.cache/restic; no named volumes are required in consumer Compose.
  • /staging holds an unencrypted database archive. New files use umask 077; protect the host directory and allow space for both the previous and next dump.
  • Credentials are read from environment variables or libpq/restic password files, not passed as command-line values. Docker administrators can inspect container environments. Keep encryption passwords in a separate password manager.
  • The scheduler runs as root. The PostgreSQL base image provides client tools, but its database server entrypoint is replaced and no database server runs in the backup container.
  • Keep a backup copy on an independent host or disk; a repository on the application's own disk does not protect against that disk failing.

Failures and health

Failed dumps do not upload a previous staging archive. Failed uploads never run retention. A successful upload followed by failed retention is still reported as a failed overall job. Success markers advance only after the complete operation succeeds.

flock prevents overlapping runs sharing one staging directory; contention exits with 75. The overall timeout terminates blocked dumps, uploads and cleanup. Partial files are discarded on exit or before the next attempt. Do not share a staging directory across unrelated services.

/staging/last-success records the last completed backup; /staging/last-failure makes health fail until a successful retry. Persistent first-start time provides an initial grace period. Container restarts do not reset freshness. Docker health is not an external alert: connect monitoring to health and verify recent restic snapshots. Tini forwards signals and reaps subprocesses.

Ping notifications

Optional HTTP notifications, inspired by vaultwarden-backup, work with webhooks, Healthchecks.io and similar monitors. All URLs are disabled by default.

Upgrading from v0.1.0: v0.2.0 defaults to POST with a JSON result body. Set PING_METHOD: GET to retain the previous bodyless GET behavior. No database or restic repository migration is required.

One URL for both success and failure

environment:
  BACKUP_NAME: my-db
  PING_URL: https://example.com/notify

After each backup attempt, the configured URL receives a POST with Content-Type: application/json and a body like this:

{
  "backup_name": "my-db",
  "status": "success",
  "exit_code": 0,
  "timestamp": "2026-09-09T14:00:00Z"
}

status is started, success or failure. A start notification has a null exit_code; completed attempts include the actual exit code (0 for success). Success means snapshot, upload, retention and success marker all completed. timestamp is the notification generation time in UTC, not the snapshot time. There is no separate event field. Database content, credentials and backup logs are not included.

Notification options

Variable Default and purpose
PING_URL Empty; notify after either success or failure
PING_URL_WHEN_START Empty; notify after acquiring the lock, before validation and backup
PING_URL_WHEN_SUCCESS Empty; notify after the complete backup succeeds
PING_URL_WHEN_FAILURE Empty; notify on validation, snapshot, upload, retention or backup timeout failure
PING_METHOD POST; accepts POST or GET
PING_TIMEOUT_SECONDS 10; positive integer timeout per request, including connection
PING_CONTENT_TYPE application/json; POST request content type
PING_PAYLOAD Unset; automatically generate result JSON, or set a literal body shared by all events
PING_PAYLOAD_WHEN_START Unset; override the shared body for start notifications
PING_PAYLOAD_WHEN_SUCCESS Unset; override the shared body for success notifications
PING_PAYLOAD_WHEN_FAILURE Unset; override the shared body for failure notifications

Set PING_URL_WHEN_START as well as PING_URL to receive both start and completion notifications. Alternatively, configure PING_URL_WHEN_SUCCESS and PING_URL_WHEN_FAILURE to use separate result URLs (they may also be identical). Leave PING_URL unset in that case unless a second completion notification is wanted: completion is sent after the success/failure notification.

Custom bodies

environment:
  PING_URL_WHEN_SUCCESS: https://example.com/notify
  PING_URL_WHEN_FAILURE: https://example.com/notify
  PING_PAYLOAD_WHEN_SUCCESS: '{"text":"my-db backup succeeded"}'
  PING_PAYLOAD_WHEN_FAILURE: '{"text":"my-db backup failed"}'

Body precedence is: event-specific payload, then PING_PAYLOAD, then automatic JSON. An explicitly empty string sends an empty body. To get automatic JSON, leave payload variables unset, rather than setting them to empty strings. The provided Compose example leaves these variables commented out for this reason. The completion notification uses PING_PAYLOAD or automatic JSON; it does not inherit PING_PAYLOAD_WHEN_SUCCESS or PING_PAYLOAD_WHEN_FAILURE.

Custom bodies replace the entire default object. They are sent verbatim, without JSON validation, template expansion, shell evaluation or added metadata. For text, set PING_CONTENT_TYPE: text/plain; YAML block scalars support multiline bodies. A leading @ is literal text, not a file reference. Environment variables cannot carry NUL bytes. GET sends no body or content-type header; an explicit GET with a nonempty custom payload is rejected instead of silently discarding the body.

Healthchecks.io

Supply token-bearing URL values out of band using the optional entries in compose.yaml (replace the placeholder with your check's ping URL):

PING_URL_WHEN_START=https://hc-ping.com/<check-uuid>/start
PING_URL_WHEN_SUCCESS=https://hc-ping.com/<check-uuid>
PING_URL_WHEN_FAILURE=https://hc-ping.com/<check-uuid>/fail

Leave PING_URL unset: sending a completion request to the base Healthchecks URL after failure would incorrectly mark the check successful. POST bodies may be recorded by your monitoring provider; check its logging policy. If the receiver requires the previous GET behavior, set PING_METHOD=GET.

Delivery and testing

Only HTTP(S) is accepted. HTTPS verifies certificates; redirects are not followed, and only 2xx responses count as delivered. Requests have no retries. URLs, request bodies and response bodies are not logged. URL/config and body are passed to curl through separate file descriptors rather than command arguments. Keep sensitive values out of Git.

Delivery or notification configuration failures produce a warning but never change the backup exit code or health markers. Notifications run outside BACKUP_TIMEOUT_SECONDS; a run can take up to three additional PING_TIMEOUT_SECONDS intervals. The staging lock is held until notifications finish. A skipped overlapping invocation (exit 75) sends no notifications. Abrupt process/container termination cannot guarantee a failure or completion ping; configure monitoring to alert on missing expected success notifications as well.

Test a configured notification without accessing the database or restic repository:

docker compose run --rm backup ping start
docker compose run --rm backup ping success
docker compose run --rm backup ping failure
docker compose run --rm backup ping completion

These commands send real HTTP requests using simulated results: start uses started/null, success and completion use success/0, and failure uses failure/1. They use BACKUP_NAME if set, otherwise an empty string. Configured custom bodies apply to tests too. A disabled URL is a successful no-op; invalid configuration or delivery failure exits nonzero. Arbitrary curl options and message templates are not supported.

Restore

First recover the dump to an isolated directory:

docker compose run --rm backup snapshots
docker compose run --rm -v /absolute/isolated-restore:/restore backup \
  restore latest --target /restore

Replace latest with a specific snapshot ID to select a recovery point. The dump will be at /absolute/isolated-restore/staging/database.dump. This is a custom archive; use pg_restore, not psql.

Create an empty target database and its owner separately. With PGHOST, PGDATABASE, PGUSER, and credentials configured for the isolated target, restore using the image's client tools:

docker compose run --rm \
  -e PGHOST -e PGDATABASE -e PGUSER -e PGPASSWORD -e PGSSLMODE \
  -v /absolute/isolated-restore:/restore:ro \
  --entrypoint pg_restore backup \
  --no-password --exit-on-error --single-transaction --no-owner --no-acl \
  --dbname="$PGDATABASE" /restore/staging/database.dump

Ensure the target is isolated and empty, all required variables are set, and the backup container can reach it. Reapply application-specific grants if needed, run ANALYZE, and verify actual rows and application behavior before a cutover. Do not restore an untrusted archive: PostgreSQL restores can execute SQL from the source database.

Public commands init, check, snapshots, restore, and dump forward additional arguments to restic. dump means restic file extraction, not a new PostgreSQL backup. Use backup for a new snapshot. Periodically run check --read-data; preserve matching application versions alongside recovery points.

Build, test and publish

Development branch: master. CI builds and tests native amd64 and arm64 images before publishing a multi-platform image to GHCR. Pull requests test without publishing. Default-branch pushes publish latest and sha-<full-commit-id>; v* tags publish semantic version tags. Third-party Actions are pinned to commits.

Integration tests restore a real database while concurrent transactions occur, exercise missing databases and wrong credentials, lock contention, timeout, retention protection, scheduler execution, persistent health and graceful shutdown. HTTP callback tests also cover lifecycle order, lock skips, delivery errors, redirects, timeouts and URL handling. They use disposable local restic repositories, not production credentials or S3. Actual S3 endpoints require separate validation.

Local Docker tests belong under /home/txchen/docker_test/postgres-backup; CI uses the runner temporary directory:

docker build -t postgres-restic-backup:test /path/to/postgres-restic-backup
python3 /path/to/postgres-restic-backup/tests/integration.py \
  --image postgres-restic-backup:test --platform linux/amd64 \
  --workdir /home/txchen/docker_test/postgres-backup

Tests remove their disposable containers and networks and retain backup files for inspection. Record image digests for deliberate upgrades; ensure restic repository format compatibility before rolling back.

Sources: PostgreSQL SQL dumps, pg_dump, pg_restore, libpq environment, restic documentation.

About

Scheduled PostgreSQL pg_dump backups with restic encryption, S3 storage, retention, and Docker Compose

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages