The restore finished clean. pg_restore exited 0, every table came back, row counts matched, the app booted. Twenty minutes later the first support message arrived: profile pictures were broken. Then the PDFs. Then every file anyone had ever uploaded.
Nothing about the restore failed. This is how Supabase Storage is built — and it applies equally to the built-in daily backups, to PITR, and to your own pg_dump. None of them include your Storage files.
Your database only stores pointers
Supabase Storage is two systems. The storage.objects table in your Postgres database holds the metadata — bucket, path, timestamps, owner. The actual file bytes live in a separate S3 backend operated by Supabase, entirely outside your database.
Restoring the database restores the pointers. If the files behind them were deleted, or you're restoring into a fresh project, those pointers now reference objects that don't exist. The dangerous part: everything looks healthy from SQL. Row counts match, constraints hold, select count(*) from storage.objects is exactly right — until something requests a file and gets a 404.
Prove it to yourself in five minutes
Ask your database what it thinks is in Storage:
select bucket_id, name, created_at
from storage.objects
order by created_at desc
limit 20;
Now dump your database with pg_dump, restore it into a local Postgres, and run the same query. Every row is there. Not one of the files those rows describe came with the dump. The restore passes every SQL check you can write and still lost your users' data.
Backing the files up
The good news: Supabase exposes the Storage backend over an S3-compatible endpoint, so the files can be copied out like any other bucket.
First, create S3 credentials. Dashboard → Storage → Settings → S3 Access Keys → enable the S3 connection and generate a key pair. The endpoint and region are shown on the same page; the endpoint looks like:
https://<project-ref>.storage.supabase.co/storage/v1/s3
One warning before anything else: these keys bypass RLS. Treat them like a service-role key — server-side only, never in client code.
Option A: DIY with rclone
Sync every bucket to storage you own — Cloudflare R2, Backblaze B2, or your own S3:
# ~/.config/rclone/rclone.conf
[supabase]
type = s3
provider = Other
access_key_id = <SUPABASE_S3_ACCESS_KEY_ID>
secret_access_key = <SUPABASE_S3_SECRET>
endpoint = https://<project-ref>.storage.supabase.co/storage/v1/s3
region = <project-region>
[r2]
type = s3
provider = Cloudflare
access_key_id = <R2_ACCESS_KEY_ID>
secret_access_key = <R2_SECRET>
endpoint = https://<account-id>.r2.cloudflarestorage.com
rclone sync supabase:<bucket> r2:my-backups/storage/<bucket> --checksum
If rclone throws signature errors against the Supabase endpoint (some releases after 1.67.0 did), the plain AWS CLI is a reliable fallback:
aws s3 sync s3://<bucket> ./storage-backup/<bucket> \
--endpoint-url "https://<project-ref>.storage.supabase.co/storage/v1/s3" \
--region <project-region>
Two things to get right if you go this route:
-
Dump the database in the same run. Schedule the
rclone syncand thepg_dumptogether (cron, or a GitHub Actions workflow), so the database snapshot and the file snapshot describe the same moment. Otherwise a restore hands you metadata rows and files that disagree with each other. - Budget for egress. Downloading your files counts as uncached egress on your Supabase bill — $0.09/GB beyond your plan's allowance (Free: 5 GB/month, enforced through a fair-use flow of notice → grace period → restrictions, so recurring large backups aren't viable there; Pro: 250 GB included). For big buckets, weekly beats daily.
Option B: an open-source CLI that does both in one run
Disclosure up front: I'm the author. The backupdrill CLI (MIT, github.com/backupdrill/cli) grew out of exactly the incident at the top of this post. One run streams the pg_dump (custom format) and the Storage files to a bucket you own, and writes a manifest with SHA-256 checksums:
npm install -g backupdrill
export BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT="https://<ref>.storage.supabase.co/storage/v1/s3"
export BACKUPDRILL_SUPABASE_STORAGE_REGION="<project-region>"
export BACKUPDRILL_SUPABASE_STORAGE_ACCESS_KEY_ID="…"
export BACKUPDRILL_SUPABASE_STORAGE_SECRET_ACCESS_KEY="…"
backupdrill backup
You'll also need the database connection string and a destination bucket — the repo covers the full configuration, ships a GitHub Actions template for scheduling, and has an estimate command that projects the egress cost before you commit to a schedule.
The part I actually built it for is backupdrill drill: it restores the latest snapshot into a throwaway Docker Postgres and verifies it — archive checksum, table count against the manifest, populated tables restored non-empty, sampled SHA-256 checks on the Storage files — then destroys the container. Because a backup you've never restored is a guess.
Option C: the hosted version
If you'd rather not babysit cron: BackupDrill runs the same open-source engine on a schedule — database and Storage files captured together into your own bucket, restore drills that prove the snapshot actually comes back, failures emailed. The free plan is one project with weekly backups and includes a restore drill on your first backup, no card.
The takeaway
Whatever you use for the database — built-in backups, PITR at $100 per month, or your own dumps — none of it covers Storage files. If users upload anything you'd mind losing, the files need their own backup path, and the database snapshot and file snapshot need to describe the same moment.
This is an expanded version of a guide originally published on backupdrill.com. I build BackupDrill; the CLI above is MIT and works without the hosted service.
Top comments (1)
How do you test that your Supabase backups actually restore? Genuinely curious what setups people run.