Backups
Postgres backups
The infra template ships scripts/backup-wrapper.example.sh: pg_dump → gzip
→ rclone copy to a remote of your choice, with retention. Idempotent for
cron: logs to stdout, exits non-zero on failure.
rclone
remote backend
30 days
default retention
Mandatory
restore drills
What the script does
Section titled “What the script does”flowchart LR pg["pg_dump --no-owner<br/>against running container"] gz["gzip<br/>local temp file"] rclone["rclone copy<br/>to remote"] retention["delete remote dumps<br/>> RETENTION_DAYS"] cleanup["remove local temp"] pg --> gz --> rclone --> retention --> cleanup
Plain bash. ~80 lines. Read it before pointing at production.
Design choices
Section titled “Design choices”rclone, not vendor-specific tools
One config talks to S3, B2, Storj, Wasabi, even your own host via SFTP.
Gzip locally before upload
Saves bandwidth and storage; pg_dump compresses well.
pg_dump, not file-level snapshots
Logical dumps survive Postgres version upgrades; file-level does not.
Retention enforced remotely
The remote is the source of truth for what exists.
BACKUP_DRY_RUN=1 mode
First run prints the plan; promote to real once you trust it.
Exits non-zero on any failure
Cron mail capture surfaces failures automatically.
What to configure
Section titled “What to configure”In compose/.env (the script sources it):
POSTGRES_USER and POSTGRES_DB
What to dump.
RCLONE_REMOTE_NAME
The rclone remote alias (configured separately via rclone config).
RCLONE_REMOTE_PATH
BACKUP_RETENTION_DAYS
BACKUP_DRY_RUN=1
Print what would happen; skip side effects.
Setting it up
Section titled “Setting it up”- Configure rclone once:
rclone configon the host. Pick a remote (S3, B2, etc.), name itbackup(or anything; matchRCLONE_REMOTE_NAME). - Copy the wrapper and make it executable:
$ cp scripts/backup-wrapper.example.sh scripts/backup-wrapper.sh
$ chmod +x scripts/backup-wrapper.sh- Dry-run first to verify the plan:
$ BACKUP_DRY_RUN=1 ./scripts/backup-wrapper.sh
# DRY RUN: would dump database 'app' to backup-2026-05-23.sql.gz
# DRY RUN: would copy to backup:db/backup-2026-05-23.sql.gz
# DRY RUN: would delete 0 remote files (retention=30 days)- Real run: drop the flag, run once manually, confirm a file lands in the remote.
- Schedule it. Drop a cron entry:
# /etc/cron.d/backups; daily at 03:1515 3 * * * root /path/to/infra/compose/scripts/backup-wrapper.shRetention math
Section titled “Retention math”Daily, 30 days
Default retention 30 backups: a month of point-in-time recovery.
Daily, with monthly retention
Custom; see below. Years of monthly snapshots, days of recent.
For longer retention (compliance, year-over-year audits), run two crons with different RCLONE_REMOTE_PATH values:
# Daily, 30 days15 3 * * * ... RCLONE_REMOTE_PATH=daily BACKUP_RETENTION_DAYS=30 ...
# Monthly, kept forever (no retention)15 4 1 * * ... RCLONE_REMOTE_PATH=monthly BACKUP_RETENTION_DAYS=99999 ...Restore test
Section titled “Restore test”$ rclone ls backup:db/
$ rclone copy backup:db/<file>.sql.gz ./
$ gunzip <file>.sql.gz
$ docker run --rm -d --name pg-restore -e POSTGRES_PASSWORD=test postgres:17
$ cat <file>.sql | docker exec -i pg-restore psql -U postgres
# backup-2026-05-22.sql.gz (3.4 MB)
ok Downloaded backup-2026-05-22.sql.gz
ok pg-restore container started
ok Restore complete. Run sanity queries against pg-restoreSanity-query row counts in the main tables; users, audit_log, whatever’s important.
Encryption
Section titled “Encryption”The script uploads gzip’d SQL; readable to anyone with access to the remote. Two patterns to encrypt:
- Server-side at the remote. S3, B2, etc. all support encryption-at-rest. Easiest; you trust the provider.
- Client-side via rclone crypt.
rclone configa crypt-wrapped remote on top of the bucket. Encrypted before leaving the host.
For sensitive data, client-side is the right answer. Document the password in your secret store; without it, the backups are useless.
What’s not backed up
Section titled “What’s not backed up”- Valkey. Cache + queues, not authoritative. If you can’t rebuild it, that’s an architecture problem, not a backup problem.
- Audit log table. It is backed up because it’s part of Postgres. Noting it because some templates treat audit separately.
compose/.env. Back up separately; it’s small enough to live in a private secrets repo or password manager.- Container images. Pull from registry on restore; not part of the backup loop.
Source
Section titled “Source”scripts/backup-wrapper.example.sh · docs/backup-offsite.md under infra/compose.
Related
Section titled “Related”- Secrets; back up
compose/.envseparately. - Deployment; where this script lives in the broader operational picture.