Skip to content
BoringStack
GitHub

Backups

5 min read

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

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.

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.

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
Path within the remote.
BACKUP_RETENTION_DAYS
Default 30.
BACKUP_DRY_RUN=1

Print what would happen; skip side effects.

  1. Configure rclone once: rclone config on the host. Pick a remote (S3, B2, etc.), name it backup (or anything; match RCLONE_REMOTE_NAME).
  2. Copy the wrapper and make it executable:
Initialize backup script
$ cp scripts/backup-wrapper.example.sh scripts/backup-wrapper.sh
$ chmod +x scripts/backup-wrapper.sh
  1. Dry-run first to verify the plan:
Dry-run backup
$ 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)
  1. Real run: drop the flag, run once manually, confirm a file lands in the remote.
  2. Schedule it. Drop a cron entry:
Terminal window
# /etc/cron.d/backups; daily at 03:15
15 3 * * * root /path/to/infra/compose/scripts/backup-wrapper.sh
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:

Terminal window
# Daily, 30 days
15 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 drill
$ 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-restore

Sanity-query row counts in the main tables; users, audit_log, whatever’s important.

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 config a 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.

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

scripts/backup-wrapper.example.sh · docs/backup-offsite.md under infra/compose.

  • Secrets; back up compose/.env separately.
  • Deployment; where this script lives in the broader operational picture.