Tenant backups
Nightly off-platform backups of tenant projects to S3 — database dumps and Storage mirrors.
Tenant backups are a nightly off-platform backup system that copies every tenant Supabase project (database and Storage) to an S3 bucket you own. Code lives in control-plane/backup/.
What a backup run does
For every tenant in the control-plane tenants table with status active, past_due, or canceled (plus any refs in BACKUP_EXTRA_REFS):
- Database —
pg_dump --format=customofpublic,auth,storage, andsupabase_migrations→db/<ref>/<timestamp>.dump. The archive is validated withpg_restore --listbefore upload. - Storage — every object in
storage.objectsis mirrored tostorage/<ref>/<bucket>/<path>. The mirror is incremental: only new or changed objects are copied. - Summary —
runs/<timestamp>.jsonlists every tenant's result. Each tenant also gets abackup.okorbackup.errorrow in the control-planecp_eventstable.
Any failure makes the job exit non-zero and posts to BACKUP_ALERT_WEBHOOK if set. One tenant failing doesn't stop the others.
No DB passwords needed
The provisioner discards tenant DB passwords, so each dump signs in with a temporary login role from the Management API (POST /v1/projects/{ref}/cli/login-role — the same mechanism supabase link uses). Only the org PAT is required. The job tries the read-only role first; if that role can't see every row due to RLS, it falls back to the postgres-member role. Each dump records which role it used in the S3 object metadata.
Connections go through the IPv4 Supavisor pooler in session mode (port 5432). The direct db.<ref> host is IPv6-only and unreachable from most container environments, including Railway.
Setup
1. S3 bucket with versioning and lifecycle rules
B=supanet-tenant-backups-<suffix>
R=us-east-1
aws s3api create-bucket --bucket $B --region $R
aws s3api put-public-access-block --bucket $B --public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-versioning --bucket $B --versioning-configuration Status=Enabled
aws s3api put-bucket-lifecycle-configuration --bucket $B --lifecycle-configuration '{
"Rules": [
{"ID":"db-dumps","Filter":{"Prefix":"db/"},"Status":"Enabled",
"Transitions":[{"Days":30,"StorageClass":"GLACIER_IR"}],
"Expiration":{"Days":90},"NoncurrentVersionExpiration":{"NoncurrentDays":1}},
{"ID":"storage-history","Filter":{"Prefix":"storage/"},"Status":"Enabled",
"NoncurrentVersionExpiration":{"NoncurrentDays":30}},
{"ID":"runs","Filter":{"Prefix":"runs/"},"Status":"Enabled","Expiration":{"Days":90}}
]}'New buckets get SSE-S3 encryption by default. The lifecycle rules transition old database dumps to cheaper Glacier storage after 30 days and delete them after 90. Storage mirrors keep 30 days of version history.
2. IAM user with write-only S3 access
Create an IAM user that can only write to that bucket. It needs s3:PutObject, s3:ListBucket, and s3:AbortMultipartUpload, with no delete permission:
aws iam create-user --user-name supanet-backup
aws iam put-user-policy --user-name supanet-backup --policy-name s3-backup --policy-document '{
"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":["s3:ListBucket"],"Resource":"arn:aws:s3:::'$B'"},
{"Effect":"Allow","Action":["s3:PutObject","s3:AbortMultipartUpload"],"Resource":"arn:aws:s3:::'$B'/*"}]}'
aws iam create-access-key --user-name supanet-backupBecause the key has no delete permission and the bucket is versioned, a leaked key cannot wipe your backups.
3. Railway cron service
In the control-plane Railway project, add a service from this repo:
- Set the variable
RAILWAY_CONFIG_FILE=infra/railway/tenant-backup.json, or manually set the Dockerfile path tocontrol-plane/backup/Dockerfileand the cron schedule to0 7 * * *(07:00 UTC). - Leave the Root Directory unset.
- Set these service environment variables:
| Variable | Value |
|---|---|
SUPANET_MGMT_PAT | The org PAT (same as the provisioner) |
CONTROL_PLANE_REF | Control-plane project ref |
BACKUP_EXTRA_REFS | Comma-separated list: origin app ref, control-plane ref, etc. (optional) |
BACKUP_S3_BUCKET | From step 1 |
AWS_REGION | From step 1 (default: us-east-1) |
AWS_ACCESS_KEY_ID | From step 2 |
AWS_SECRET_ACCESS_KEY | From step 2 |
BACKUP_ALERT_WEBHOOK | Slack incoming webhook or any service accepting {"text": …} (optional, recommended) |
BACKUP_S3_PREFIX | Optional S3 prefix (default: empty) |
BACKUP_SCHEMAS | Optional comma-separated schema list (default: public,auth,storage,supabase_migrations) |
Deploy the service to run it immediately. Check that runs/<timestamp>.json in S3 shows "failed": 0.
4. Local testing (optional)
To run the backup job locally:
cd control-plane
npm install
cp .env.example .env # fill in the Backups block
npm run backupRequires pg_dump 17+ on your PATH (the PostgreSQL major version must be >= every tenant's Postgres).
Restore
Restore into a new Supabase project (Supabase creates the auth and storage schemas itself):
aws s3 cp s3://$B/db/<ref>/<timestamp>.dump tenant.dump
DB='postgresql://postgres.<newref>:<pw>@<pooler-host>:5432/postgres?sslmode=require'
# Application schema + data, and migration history
pg_restore --no-owner --no-privileges -n public -n supabase_migrations -d "$DB" tenant.dump
# Users and storage metadata (triggers off while loading)
PGOPTIONS='-c session_replication_role=replica' \
pg_restore --data-only --no-owner -n auth -n storage -d "$DB" tenant.dumpThen:
- Storage bytes —
aws s3 sync s3://$B/storage/<ref>/ ./objects, then upload each object back to the same<bucket>/<path>with upsert. Storage metadata rows are already restored. - Edge functions —
supabase functions deploy --project-ref <newref>and re-enter function secrets. - Crons — Call
setup_automation_cron(). Thescripts/apply-migrations.tsscript does this. - Vault secrets — Re-enter them in Settings. Vault ciphertext is tied to the old project's encryption key and won't decrypt elsewhere.
- Control plane — Update the tenant row's
project_refto point at the new project, and update the tenant's Railway env to use the new Supabase URL and keys.
Not covered
- Vault secret values — these are tied to the old project and must be re-entered.
- Edge-function secrets — these live outside the database and must be re-set in the new project.
- Supabase-managed schemas —
cron,net,realtime, etc. are rebuilt by migrations andsetup_automation_cron. - Point-in-time recovery — this is a daily snapshot. For PITR, use Supabase's paid-plan add-on.
See also
- Control plane — tenant provisioning and fleet management
- Deploy — self-hosted deployment