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.
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 backupPre-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.
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.
| 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.
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.
- Bind
./backup-staging:/stagingand./backup-cache:/root/.cache/restic; no named volumes are required in consumer Compose. /stagingholds an unencrypted database archive. New files useumask 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.
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.
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.
environment:
BACKUP_NAME: my-db
PING_URL: https://example.com/notifyAfter 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.
| 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.
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.
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>/failLeave 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.
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 completionThese 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.
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 /restoreReplace 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.dumpEnsure 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.
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-backupTests 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.