| File | Purpose |
|---|---|
render-backup.sh |
Unattended logical backup with rotation, verification and an expiry countdown |
restore-into.sh |
Restore a dump into any Postgres server, with a rollback snapshot of the target |
schedule.md |
cron, systemd timer and GitHub Actions recipes for the backup script |
pg_dump, pg_restore and psql at version 16 or newer. Older clients cannot
read newer servers' archives, and the error you get is not helpful.
pg_dump --version # should be >= the Render server's major versioncurl is optional and only used for ALERT_WEBHOOK. flock is optional; the
script falls back to a directory lock without it.
Render dashboard → your Postgres instance → Connect → External. Use the external URL, not the internal one: the internal hostname resolves only from inside Render's network, so a backup running anywhere else will fail with a DNS error that looks like a credentials problem.
export DATABASE_URL='postgresql://user:password@host.oregon-postgres.render.com/dbname'export DB_CREATED_AT='2026-08-01' # the date you created the instance
export BACKUP_DIR=./backups
export KEEP=14
./render-backup.shOutput is one directory-format dump plus a schema-only .sql file per run. The
schema file is small and diffable — worth keeping in a repository so you can
see when a migration changed something you did not expect.
The script verifies every dump with pg_restore --list before it reports
success, because a dump that cannot be listed is not a backup, and this failure
is silent otherwise.
| Code | Meaning | What a scheduler should do |
|---|---|---|
| 0 | Backup taken, expiry not near | nothing |
| 1 | Backup failed | page someone |
| 2 | Misconfigured (missing DATABASE_URL, missing tooling) |
fix the config |
| 3 | Backup taken, but the instance is within 7 days of expiry | act on it |
Code 3 exists because "the backup worked" and "everything is fine" are different statements, and a scheduler that only checks for zero will tell you everything is fine right up until the database is deleted.
export DUMP_PATH=./backups/20260818-031700.dump
export TARGET_DSN='postgresql://freebase@HOST:5432/mydb'
./restore-into.shFive steps: check the archive, check the target, snapshot the target if it is
not empty, restore, then ANALYZE and print row counts and sequence positions.
Restoring into a throwaway container is the only way to know the backups work.
schedule.md ends with a four-minute drill; do it monthly.
Every value comes from the environment. Keep them in a .env that is chmod 600 and covered by .gitignore — which it already is in this repository. A
dump file and a live connection string in the same directory is a bad
combination to accidentally push.