SafeGrd / Heroku Postgres backup

How to back up Heroku Postgres, and keep the copy outside Heroku

Heroku Postgres has two kinds of backup: PGBackups, which are pg_dump files Heroku takes and keeps for you, and rollback, which makes a new database as it was at an earlier moment. Both live with the add-on. When the add-on is removed, by a person, a script with the API key, or an account that lapses, its backups go after a short grace period. This page is the copy that stays: the connection string, the command, where the file goes, the restore, and the check. The last section is how SafeGrd runs it with no server of your own.

What Heroku keeps, by plan

From Heroku's Dev Center, read on 2026-10-09:

PlanManual backupsScheduled backupsRollback
EssentialLast 57 daily, 1 weeklyNone
StandardLast 257 daily, 4 weeklyUp to 4 days
PremiumLast 507 daily, 8 weekly, 12 monthlyUp to 7 days

1. The connection string

The app's DATABASE_URL config var holds it. Heroku updates that var when the credential rotates, so a backup that runs outside Heroku should read it at the time it runs rather than keep a copy:

heroku config:get DATABASE_URL -a example-app

Heroku Postgres requires TLS; add ?sslmode=require if the string does not carry it. A database in a Private or Shield space is reachable only from inside the space, so the backup runs on a dyno there.

2. A read-only credential (Standard and above)

The default credential owns the database and can write. On Standard, Premium, Private and Shield plans, make one for the backup:

heroku pg:credentials:create DATABASE --name backup -a example-app

A new credential starts with CONNECT only. As the default credential, grant it read access (GRANT pg_read_all_data TO backup; on PostgreSQL 14 and later), then attach it to an app to get its string as a config var. Essential plans have only the default credential, so the backup uses that one; keep it in one secret store and give it to nothing else.

3. Dump it

pg_dump --format=custom --no-owner --no-privileges --file=app.dump "$DATABASE_URL"

This is the format PGBackups writes, so heroku pg:backups:restore takes it too. --no-owner --no-privileges leaves out Heroku's generated role names, which do not exist anywhere else. Match the client to the database's major version (heroku pg:info shows it): an older pg_dump refuses a newer server.

To keep Heroku's own backup instead of taking a second one, heroku pg:backups:download fetches the newest, and heroku pg:backups:url gives a link that expires after 60 minutes.

4. Encrypt it, ship it, schedule it

A dump holds every row in the clear. Encrypt it on the way out and write it to a bucket in an account that is not Heroku's and not the application's:

set -o pipefail
DATABASE_URL=$(heroku config:get DATABASE_URL -a example-app)
pg_dump -Fc --no-owner --no-privileges "$DATABASE_URL" | age -r "$AGE_RECIPIENT" | aws s3 cp - "s3://$BUCKET/heroku/$(date -u +%F).dump.age"

A scheduled GitHub Actions workflow runs it with HEROKU_API_KEY in the repository's secrets, after installing the Heroku CLI, which GitHub's Ubuntu runners do not carry; the Supabase page has a 10-line workflow to start from. set -o pipefail makes a failed dump fail the job instead of uploading a cut-off file. The bucket policy, a key that cannot delete and the lock are in the S3 guide and the Object Lock guide.

5. Restore

Into Heroku: provision a new add-on and restore over its string, or upload the dump and use heroku pg:backups:restore with a signed URL. Into anything else: create an empty database on any PostgreSQL of the same or a newer major version.

aws s3 cp "s3://$BUCKET/heroku/2026-10-09.dump.age" - | age -d -i backup-key.txt > app.dump
pg_restore --exit-on-error --no-owner --no-privileges --dbname "$RESTORE_URL" app.dump

--exit-on-error stops at the first failure instead of carrying on and reporting success. The dump says CREATE EXTENSION for each extension the database used; a target that lacks one fails there, so install it first.

6. Check what came back

Compare each table's row count in the restored database with the source, as How to test that a backup restores shows, and do it on a schedule.

How SafeGrd does it

SafeGrd takes the backup from a scheduled GitHub Actions job, or on a machine it starts for each backup from the connection string, and encrypts it before upload. Each backup uploads only what changed since the last one. A drill restores the newest snapshot into a throwaway PostgreSQL of the same major version and records the tables and rows that came back, and the console alerts when a backup fails or does not run. SafeGrd does the same job on every platform; the PostgreSQL page has what it dumps, how it encrypts, and what a drill checks.

Run your first Fire Drill The GitHub Actions setup

- run: curl -fsSL https://cli-assets.heroku.com/install.sh | sh
- run: |
    url=$(heroku config:get DATABASE_URL -a example-app)
    echo "::add-mask::$url"
    echo "DATABASE_URL=$url" >> "$GITHUB_ENV"
  env:
    HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }}
- uses: safegrd/backup-action@v0
  with:
    config: ${{ secrets.SAFEGRD_CONFIG }}
    database-url: ${{ env.DATABASE_URL }}

The setup is the Supabase guide's, with the string read from Heroku on each run so a rotated credential does not break the backup. This page's Heroku steps have not been run against a Heroku app by SafeGrd yet; the Action and the CLI are the ones the Supabase guide runs. Back up on SafeGrd under Surfaces in the console takes a string too, and a machine SafeGrd starts for each backup dumps and encrypts it, then is destroyed (how). Replace the string there after a credential rotation.

Questions

Should I turn off PGBackups?

Keep them. Heroku's scheduled backups and rollback are the quick undo inside Heroku, and they come with the plan. The copy outside Heroku is for when the add-on, the app or the account is what was lost.

My database is over 20 GB.

Heroku points larger databases at rollback and followers in place of PGBackups. A pg_dump of your own still works at that size; run it against a follower so the read stays off the leader.

Related