SupaNet
Building on SupaNet

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):

  1. Database — pg_dump --format=custom of public, auth, storage, and supabase_migrations → db/<ref>/<timestamp>.dump. The archive is validated with pg_restore --list before upload.
  2. Storage — every object in storage.objects is mirrored to storage/<ref>/<bucket>/<path>. The mirror is incremental: only new or changed objects are copied.
  3. Summary — runs/<timestamp>.json lists every tenant's result. Each tenant also gets a backup.ok or backup.error row in the control-plane cp_events table.

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-backup

Because 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 to control-plane/backup/Dockerfile and the cron schedule to 0 7 * * * (07:00 UTC).
  • Leave the Root Directory unset.
  • Set these service environment variables:
VariableValue
SUPANET_MGMT_PATThe org PAT (same as the provisioner)
CONTROL_PLANE_REFControl-plane project ref
BACKUP_EXTRA_REFSComma-separated list: origin app ref, control-plane ref, etc. (optional)
BACKUP_S3_BUCKETFrom step 1
AWS_REGIONFrom step 1 (default: us-east-1)
AWS_ACCESS_KEY_IDFrom step 2
AWS_SECRET_ACCESS_KEYFrom step 2
BACKUP_ALERT_WEBHOOKSlack incoming webhook or any service accepting {"text": …} (optional, recommended)
BACKUP_S3_PREFIXOptional S3 prefix (default: empty)
BACKUP_SCHEMASOptional 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 backup

Requires 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.dump

Then:

  • 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(). The scripts/apply-migrations.ts script 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_ref to 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 and setup_automation_cron.
  • Point-in-time recovery — this is a daily snapshot. For PITR, use Supabase's paid-plan add-on.

See also

On this page