AWS Builder Center

Back up a Supabase database to Amazon S3 on a schedule, and prove it restores

A least-privilege S3 bucket for Supabase backups, a scheduled job that writes to it, and a restore drill that checks the backup before you need it.

This post walks through keeping your own copy of a Supabase database in Amazon S3. Supabase runs Postgres for you and takes its own backups, but those backups live in the same Supabase account as the database. On projects that use the newer physical backup process you can't download them. A copy in an S3 bucket you control covers the cases theirs doesn't: a deleted project, a locked account, or a move to another host.
By the end you will have:
  • an S3 bucket and an IAM user whose key can write backups but can't delete them
  • a lifecycle rule that moves old snapshots to a cheaper storage class and expires them
  • a weekly job that dumps the database into the bucket
  • a restore drill that pulls the newest snapshot back out, restores it into a throwaway Postgres and checks it
I work on BackupDrill, and the tool used here is its open-source CLI ( backupdrill  on npm, MIT licensed). I'll also show the plain  pg_dump  and  aws  CLI version first, so you can see what the tool adds and decide whether you need it.

The two-command version

If all you want is a dump in S3, this works:
pg_dump --format=custom --schema=public --file=app.dump "$DATABASE_URL"
aws s3 cp app.dump "s3://my-backups/manual/$(date -u +%Y-%m-%dT%H-%M-%SZ)/app.dump"
Use the Session pooler connection string from the Supabase dashboard (the Connect button at the top of the project page). Your  pg_dump  has to be at least the same major version as the project's Postgres. Supabase runs 15 to 17, so a version 17 client is the safe choice.
What this leaves out:
  • Supabase Storage files. This public-only dump has neither the files nor the  storage.objects  rows that describe them.
  • Any record of what the dump should contain, so a truncated upload looks the same as a good one.
  • Any check that the file restores. Until you restore it once, you don't know.
The rest of this post fills those gaps.

Step 1: Create the bucket

In the S3 console, create a bucket and keep Block Public Access on. That's the default, and a backup bucket has no reason to be public.
Pick the region on purpose. Reading data across regions is billed, so restores and drills are cheapest in the bucket's own region. If your recovery plan is "start a Postgres on EC2", put the bucket in the region where that instance would run.

Step 2: An IAM user that can't delete backups

Create a dedicated IAM user with no console access. It exists only to hold one access key for one bucket. Attach this inline policy, replacing  my-backups  with your bucket name:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload"],
"Resource": "arn:aws:s3:::my-backups/*"
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::my-backups"
}
]
}
That's everything the backup and the drill need. They write objects, read them back, clean up multipart uploads that didn't finish, and list the bucket to find snapshots.
There's no  s3:DeleteObject  in it on purpose. This key will end up as a CI secret, and if it leaks, whoever has it can't wipe your backup history. One caveat: a key that can write can also overwrite an object under an existing name. If that matters to you, turn on versioning for the bucket as well.
Then open the user's Security credentials tab, create an access key, and copy the Access Key ID and Secret Access Key. The CLI takes a static key pair, so an instance role on its own won't do here.

Step 3: A lifecycle rule for old snapshots

Since the key can't delete anything, a lifecycle rule does the cleaning. Go to the bucket's Management tab, create a lifecycle rule, and scope it to the  backupdrill/  prefix (the CLI's default key prefix):
  • Days 0 to 30: S3 Standard. These are the snapshots a drill reads and a real recovery would use.
  • Day 30: move to Standard-IA. In us-east-1 that's $0.0125 per GB-month against $0.023 for Standard. Standard-IA charges $0.01/GB when you read data back and bills at least 30 days of storage per object. AWS won't move objects into it before they're 30 days old anyway.
  • Expire snapshots past your retention window in the same rule.
Keep the snapshots you drill out of Glacier Flexible Retrieval and Glacier Deep Archive unless you have a compliance reason for a long tail. Objects in those two classes have to be restored from the archive before you can download them, so nothing that reads snapshots directly can use them in place, and that includes a restore drill. Glacier Instant Retrieval can be read directly, but charges per GB retrieved.

Step 4: First backup with the CLI

You need Node.js 20 or newer and  pg_dump  17 on the machine. Set the connection and the bucket through environment variables:
export BACKUPDRILL_DATABASE_URL="postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres"
export BACKUPDRILL_PROJECT_NAME="my-app"
export BACKUPDRILL_S3_REGION="us-east-1" # the bucket's region
export BACKUPDRILL_S3_BUCKET="my-backups"
export BACKUPDRILL_S3_ACCESS_KEY_ID="..."
export BACKUPDRILL_S3_SECRET_ACCESS_KEY="..."
# no BACKUPDRILL_S3_ENDPOINT: leaving it unset means AWS S3

npx backupdrill backup
The region matters. Without it the CLI defaults to  auto , which is what Cloudflare R2 expects, and AWS needs the real region name.
Before it dumps anything, the CLI compares your  pg_dump  major version with the server's and refuses to run if the client is older. An older  pg_dump  against a newer server is a common way to end up with no usable backup.
Each run writes a new timestamped folder, so nothing is overwritten:
backupdrill/my-app/2026-11-07T11-35-38-795Z/
dump.pgcustom # pg_dump --format=custom, public schema by default
auth.dump # Supabase Auth users, when the role can read them
storage/... # Storage files, only if you configure Storage keys
RECOVERY.md # how to restore this snapshot by hand
manifest.json # tables, row counts, file list, sizes and sha256
Both dumps are ordinary  pg_dump  output, so a stock  pg_restore  can read them without the tool.
To include Storage files, enable S3 access in the Supabase dashboard under Storage → Settings → S3 Access Keys, then add the endpoint, region and key pair as  BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT ,  _REGION ,  _ACCESS_KEY_ID  and  _SECRET_ACCESS_KEY . Leave them out and you get the database only.

Step 5: Run it every week

The simplest scheduler that doesn't need a server is GitHub Actions. In a private repository, add  .github/workflows/supabase-backup.yml :
name: supabase-backup
on:
schedule:
- cron: "0 3 * * 0" # Sundays at 03:00 UTC
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: supabase-backup
cancel-in-progress: false
jobs:
backup:
runs-on: ubuntu-latest
steps:
- uses: backupdrill/supabase-backup-action@v1
with:
database-url: ${{ secrets.BACKUPDRILL_DATABASE_URL }}
s3-bucket: ${{ secrets.BACKUPDRILL_S3_BUCKET }}
s3-access-key-id: ${{ secrets.BACKUPDRILL_S3_ACCESS_KEY_ID }}
s3-secret-access-key: ${{ secrets.BACKUPDRILL_S3_SECRET_ACCESS_KEY }}
s3-region: ${{ secrets.BACKUPDRILL_S3_REGION }}
project-name: my-app
drill: "true"
Add the four required secrets plus  BACKUPDRILL_S3_REGION  under Settings → Secrets and variables → Actions. The action installs  pg_dump  17 from the PostgreSQL apt repository, runs the backup and, with  drill: "true" , runs the restore drill from step 6 right after it on the runner.
If you'd rather keep it on an EC2 instance you already run, there's a container image that includes the CLI and  pg_dump  17. Set  BACKUPDRILL_CRON  and it stays up and runs the backup on that schedule:
docker run -d --name backupdrill --restart unless-stopped \
-e BACKUPDRILL_CRON="0 3 * * 0" \
-e BACKUPDRILL_DATABASE_URL="postgresql://..." \
-e BACKUPDRILL_PROJECT_NAME="my-app" \
-e BACKUPDRILL_S3_REGION="us-east-1" \
-e BACKUPDRILL_S3_BUCKET="my-backups" \
-e BACKUPDRILL_S3_ACCESS_KEY_ID="..." \
-e BACKUPDRILL_S3_SECRET_ACCESS_KEY="..." \
ghcr.io/backupdrill/backupdrill:latest
The container doesn't get access to the host's Docker daemon, so it can't run drills. Run those from the host itself or from the GitHub Action.
Weekly is a deliberate default. Every backup reads the whole database out of Supabase, and that counts as egress on your Supabase bill.  npx backupdrill estimate --plan pro  measures your project and projects the monthly cost before you move to daily.

Step 6: Prove it restores

npx backupdrill drill
The drill needs Docker and  pg_restore  17 on the machine. It downloads the newest snapshot, starts a throwaway Postgres container, restores into it and compares the result with the manifest. Then it removes the container. Among other things, it checks that:
  • the dump's sha256 matches what the backup recorded, so a truncated or corrupted object fails before anything is restored
  •  pg_restore  finished without errors outside a short list of expected ones, such as  schema "public" already exists 
  • every table in the manifest exists, and none that had rows came back empty
  • every ordinary table has exactly the row count the dump holds (counted from the dump file, so no extra queries hit production). Materialized views, partitioned parents and tables created by extensions are only checked for being non-empty
  • a sample of Storage files still matches its checksums, if the snapshot has files ( --verify-all-files  checks all of them)
On a 14-table test database, the end of a passing run looks like this:
✓ table count — restored 14, manifest 14
✓ no missing tables — all manifest tables present
✓ populated tables came back — no populated table restored empty
✓ row counts match the backup — all 12 tables match the backup exactly (1,476,250 rows)
✓ Drill PASSED — snapshot 2026-11-07T11-35-38-795Z, 14 tables / 1,496,250 rows restored in 1.8s
A failed drill exits with code 1, so a scheduled job shows up as failed in GitHub Actions or whatever runs it. If you have checks of your own,  --check-cmd "npm run smoke"  runs your command against the restored copy (its connection string is in  BACKUPDRILL_SANDBOX_URL ) and fails the drill if the command exits non-zero.
Keep the AWS side of the bill in mind here. A drill that runs outside AWS, on a GitHub runner or your laptop, downloads the snapshot over the internet. Your account gets 100 GB a month of data transfer out free across all services, and after that it's $0.09/GB in us-east-1. For a large database, running the drill on an instance in the bucket's region keeps that cost down.

What you have now

A key that can add backups but can't remove them, a lifecycle rule that keeps the bucket from growing forever, a weekly snapshot, and a drill that fails loudly if that snapshot doesn't restore. The longer write-up on  backing up Supabase to S3  has the cost breakdown per storage class and the config-file version of step 4.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article