How to back up Supabase Storage buckets
Nothing Supabase sells backs up your Storage files — not the daily backups on Pro, not the $100/month PITR add-on, not a pg_dumpyou run yourself. They all cover the database, and the database only holds pointers to your files. To back the files up you enable the project’s S3 endpoint and copy the buckets out. Two free ways to do that are below, with commands, plus how to put files back and how to check nothing was missed.
Why database backups leave Storage behind
Supabase Storage is two systems. The storage.objects table in your Postgres database holds the metadata — bucket, path, size, timestamps, owner. The bytes live in a separate object store operated by Supabase, outside your database entirely. Every backup of the database, however it is taken, contains at most the rows — a pg_dump --schema=public skips even those — and never the files.
The failure is quiet. Restore the database into a fresh project and run select count(*) from storage.objects— the number matches the original exactly. Every constraint holds, every query works, and every avatar, upload, and PDF is a 404 the first time a user asks for it. A restore can pass every SQL check you can write and still have lost your users’ data.
Step 1: enable S3 access and create keys
Supabase exposes the object store over an S3-compatible endpoint, so anything that speaks S3 can copy from it. In the dashboard go to Storage → Settings, turn on S3 access, and create an access key pair — you get an access key ID and a secret, shown once. You also need two values from the same page:
# both are shown under Storage → Settings
STORAGE_ENDPOINT="https://<project-ref>.storage.supabase.co/storage/v1/s3"
STORAGE_REGION="<project-region>" # e.g. us-east-1Keys are project-wide, so one pair reaches every bucket. Treat the secret like the database password: it can read (and write) every file your users have uploaded.
Method 1: rclone — free, from any machine
rclone is the standard tool for moving files between object stores. Add a remote for the project — either through rclone config or by appending this to ~/.config/rclone/rclone.conf:
[supabase]
type = s3
provider = Other
access_key_id = <access-key-id>
secret_access_key = <secret-access-key>
endpoint = https://<project-ref>.storage.supabase.co/storage/v1/s3
region = <project-region>
force_path_style = trueforce_path_style = true matters — Supabase addresses buckets by path, not by subdomain, and without it every request fails with a confusing DNS error. Check the remote, then copy a bucket:
rclone lsd supabase: # lists your buckets
export RUN="$(date +%Y%m%dT%H%M%S)" # one folder per run
rclone copy supabase:avatars "./supabase-backup/$RUN/avatars" --progressTwo choices in that command are deliberate. copy rather than sync: sync mirrors deletions, so a file a user removed by mistake disappears from the backup on the next run — the opposite of what a backup is for. And a fresh folder per run rather than updating one folder in place: rclone decides what to skip by comparing size and time, or hashes with --checksum, and Supabase’s S3 endpoint does not always expose a hash for objects that were uploaded in parts, so a same-sized replacement can be skipped as “unchanged” and never reach the backup. A full copy into a new folder has nothing to compare against and cannot make that mistake; it costs egress every run, which the FAQ below sizes. If a run is interrupted, re-run it with the same RUN value — finished files are skipped, the rest complete. To land the files in a bucket you own instead of a laptop, define a second remote (the bucket setup guide has the R2, S3, and B2 values) and copy remote to remote:
rclone copy supabase:avatars "r2:my-backups/supabase-storage/$RUN/avatars" --metadata--metadatacarries each object’s headers — Content-Type, Cache-Control, Content-Encoding— along with the bytes, so a gzip-encoded asset is still served correctly after it is put back. A local folder cannot hold those headers, which is one more reason to prefer a bucket as the destination. The per-run folder also protects you from yourself: with one folder updated in place, a file overwritten by mistake in Supabase is overwritten in the backup on the next run and the only good version is gone. Dated folders keep every version until your bucket’s lifecycle rule expires them — set one, or old runs accumulate at object-storage prices. Put that in cron or a GitHub Actions workflow and you have a Storage backup. What it does not give you is a copy that matches a specific database backup: the files are current as of the copy, the rows are current as of the dump, and if the two ran an hour apart they describe slightly different worlds.
Method 2: the open-source CLI — database and files in one snapshot
The backupdrill CLI (MIT, github.com/backupdrill/cli) narrows the matching problem: one run dumps the database and then copies every file, into one snapshot folder in your bucket, with a manifest that records a sha256 for each file — so rows and files are at least from the same run and travel together. It is not a single point in time: the dump finishes before the file copy starts, so a file uploaded or deleted during the run can still disagree with its row. For a truly consistent capture, pause writes for the duration. Give it the database connection string and destination bucket as in the CLI docs, then add the Storage keys:
export BACKUPDRILL_SUPABASE_STORAGE_ENDPOINT="https://<project-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="…"
# optional — uncomment to limit the copy to specific buckets (default: every bucket)
# export BACKUPDRILL_SUPABASE_STORAGE_BUCKETS="avatars,uploads"
npx backupdrill backupThe same tool restores the snapshot into a fresh project — backupdrill restoreloads the database and writes the files back into the target project’s Storage, reconciling every key and spot-checking checksums. Separately, backupdrill drill restores the database into a throwaway Postgres and verifies a sample of the file copies in your bucket against the manifest checksums — proof that the backup is intact, without touching anything real. It does not upload files into a Supabase project; only the restore command does that.
Method 3: hosted, on a schedule
BackupDrill runs that engine for you. Connect a project, and each scheduled snapshot captures the database; add the project’s Storage S3 keys on its page (any time after the first backup) and the files join the same snapshot, checksummed. Restore drills on paid plans then restore the database from the latest snapshot into a throwaway Postgres and verify a sample of the file copies by sha256 against the manifest — so the report says the backup is intact, rows and files, not just that a job ran. Getting files back into a project is the CLI’s job: the hosted restore wizard restores the database into a fresh project and, when the snapshot includes Storage files, hands you the exact Storage-only CLI command to run next. Everything lands in your own bucket; the quickstart covers setup and the coverage page lists exactly what is and is not included.
Putting files back
A file restore is the database restore in reverse order: load the database dump first, so storage.objectsand the bucket definitions exist, then copy the files into the new project’s endpoint under the same bucket names and paths. With rclone that is the copy command with the remotes swapped — with one flag that matters:
export RUN="20260907T031500" # the run you are restoring from
rclone copy "r2:my-backups/supabase-storage/$RUN/avatars" supabase-new:avatars --ignore-times --metadata
rclone check "r2:my-backups/supabase-storage/$RUN/avatars" supabase-new:avatars --download--ignore-times is not optional here. Because you restored the database first, the target project already has storage.objectsrows with each file’s size and ETag, and the S3 endpoint answers listing requests from that metadata — so rclone’s normal size-and-hash comparison can decide a file is already there when no bytes exist behind the row, skip it, and finish with the file still a 404. Forcing every upload removes that trap, --metadatarestores each object’s headers with it, and rclone check --download then pulls each object back and compares real bytes, not metadata. Two more details bite. The bucket has to exist in the target project before the copy — create it in the dashboard with the same name and public or private setting. And the paths must match the rows exactly, because a row whose file is at a different key is still a 404. The restore docs cover the CLI path, which handles both.
Check nothing was missed
Ask the database what it thinks is in each bucket, then ask the copy:
-- in the SQL editor
select bucket_id, count(*) as files, sum((metadata->>'size')::bigint) as bytes
from storage.objects
group by bucket_id
order by bucket_id;rclone size "./supabase-backup/$RUN/avatars" # or "r2:my-backups/supabase-storage/$RUN/avatars"Measure the copy, not the source — the point is to catch a backup that is short. Counts and bytes should agree bucket by bucket. If the copy is short, the usual reasons are a bucket filtered out, a key pair created before a bucket existed, or a run that was interrupted — re-run the copy; rclone picks up where it stopped.
The takeaway
Whatever you use for the database, the files need their own path out, and the safest version is the one where files and rows are captured together and restore-tested together. For the wider decision, see every Supabase backup option compared.
FAQ
Does Supabase back up Storage files?
No. Supabase's daily backups and the PITR add-on cover the Postgres database only. The storage.objects table — bucket, path, size, owner — is in those backups, but the file bytes live in a separate object store that no database backup touches. Restore a database and every file URL comes back pointing at nothing.
How do I back up Supabase Storage?
Enable S3 access under Storage → Settings, create an access key pair, and copy the buckets out through the S3-compatible endpoint. rclone does it for free from any machine; the open-source backupdrill CLI copies files and database in one snapshot with a per-file checksum; BackupDrill's hosted service does the same on a schedule once you add the Storage keys to a project.
Can I download all files from a Supabase bucket at once?
Yes, through the S3 endpoint rather than the dashboard. rclone copy supabase:<bucket> ./local-folder pulls an entire bucket, including nested folders, and can resume if interrupted. The dashboard downloads one file at a time.
Does Supabase PITR include Storage files?
No. Point-in-time recovery rewinds the database to a chosen second; the object store is untouched. After a PITR restore, files uploaded after the restore point still exist but their rows are gone, and rows for files deleted since then point at nothing.
How much does it cost to back up Supabase Storage?
The tools can be free — rclone and the backupdrill CLI both are. The cost is egress: every file you copy out counts against your Supabase plan's bandwidth (5 GB/month included on Free, 250 GB on Pro, then per-GB overage), plus whatever your destination charges to store it. A 2 GB bucket copied weekly is about 8 GB of egress a month.
Sources
- Supabase docs — Database Backups
- Supabase docs — Storage S3 Authentication
- Supabase docs — S3 compatibility
- rclone docs — Amazon S3 and compatible providers
- Supabase — Pricing (egress included per plan)
Facts and prices last verified September 7, 2026 against the sources above. Written by the team behind BackupDrill.
Database and Storage files, backed up together into your own bucket, with drills that prove they restore. Start free — the free plan covers one project.