Guides / Back up PostgreSQL to S3

How to back up PostgreSQL to S3 with pg_dump

This guide builds a nightly PostgreSQL backup to an S3 bucket with standard tools: pg_dump, age for encryption and the AWS CLI. The script stops on the first error, the file is encrypted before it leaves the server, and the bucket refuses to delete it for a set number of days. It works with any S3-compatible storage that supports Object Lock.

What you need

1. Make an encryption key

Generate the key on your laptop or in your secrets manager, not on the database server. The server only needs the public half, so a compromised server cannot read old backups.

age-keygen -o backup-key.txt
# prints: Public key: age1...

Store backup-key.txt in at least two places that do not depend on the server or the bucket. Without it the backups cannot be decrypted by anyone.

2. The backup script

#!/usr/bin/env bash
set -euo pipefail
 
DB_URL="postgres://backup@db.internal:5432/app"
RECIPIENT="age1..." # the public key from step 1
BUCKET="acme-db-backups"
KEY="app/$(date -u +%Y/%m/%d/app-%Y%m%dT%H%M%SZ).dump.age"
 
pg_dump --format=custom --no-owner "$DB_URL" \
  | age -r "$RECIPIENT" \
  | aws s3 cp - "s3://$BUCKET/$KEY"
 
echo "uploaded s3://$BUCKET/$KEY"

Why each part is there:

3. Give the server a key that cannot delete

The access key on the database server should be able to write backups and nothing else. If it can delete, anyone who takes over the server can remove your backups before they touch the database.

{"Version": "2012-10-17", "Statement": [
  {"Effect": "Allow", "Action": ["s3:PutObject", "s3:AbortMultipartUpload"],
   "Resource": "arn:aws:s3:::acme-db-backups/*"}
]}

With a default retention rule on the bucket, every object this key writes is locked without the key needing s3:PutObjectRetention. Keep a separate read-only key for restores.

4. Schedule it, and notice when it stops

# /etc/cron.d/pg-backup: 02:15 UTC every night
15 2 * * * backup /usr/local/bin/pg-backup.sh >> /var/log/pg-backup.log 2>&1

Cron sends no alert when a job fails, and none when it stops running because the server was rebuilt without it. Add a check that expects a backup every day and alerts when one does not arrive: a heartbeat URL the script calls on success, or a daily job that lists the bucket and checks the newest object's date and size.

5. Restore

createdb app_restored
aws s3 cp "s3://acme-db-backups/app/2026/09/29/app-20260929T021500Z.dump.age" - \
  | age -d -i backup-key.txt \
  | pg_restore --exit-on-error --no-owner -d app_restored

pg_restore reads a custom-format archive from standard input, but a parallel restore with -j needs a file it can seek in. For a large database, decrypt to a file first and restore that with -j 4 or more.

6. Test the restore on a schedule

Everything above produces a file. None of it proves the file restores. Run step 5 into a scratch database regularly and compare row counts with the source. How to test that a backup restores has the queries, including how to count from the same snapshot pg_dump read.

How SafeGrd does it

SafeGrd's daemon does all six steps. It dumps the schema with pg_dump and the rows over binary COPY from one snapshot, compresses with zstd, encrypts with age on your server and writes to your bucket under Object Lock, with a host key that cannot delete. It alerts when a backup fails or does not run, and on paid plans it restores the newest backup on a schedule and checks every table's row count against the manifest it wrote at backup time. See Databases for setup, or pricing.