Backup and restore
The full runbook with every detail lives at docs/BACKUP_AND_RESTORE.md. This page is a condensed operations view — enough to act in an incident, with pointers back for specifics.
What runs automatically
.github/workflows/supabase-backup.yml runs daily at 02:00 UTC (07:30 IST — low-traffic for India ops). Each run produces:
schema-<UTC-timestamp>.sql.gz— DDL onlydata-<UTC-timestamp>.sql.gz— row data onlychecksums.txt— SHA-256 of both.gzfiles
The dump runs pg_dump inside a Postgres container that the Supabase CLI pulls. The workflow pulls it from Amazon's public mirror (SUPABASE_INTERNAL_IMAGE_REGISTRY: public.ecr.aws), not ghcr.io, because GitHub's runners share one anonymous ghcr.io rate limit and three nightly runs on 2026-09-23/24 failed with toomanyrequests before dumping anything. If a run fails at "Dump roles" with a pull error, that is the place to look; the CI database job uses the same setting.
These are uploaded as a GitHub Actions artifact supabase-backup-YYYY-MM-DD with 30-day retention. If the optional AWS_S3_BUCKET secret is configured, the same files are copied to s3://$AWS_S3_BUCKET/supabase-backups/YYYY-MM-DD/ (S3 lifecycle governs long-term retention).
RPO / RTO
- RPO (Recovery Point Objective): up to 24 hours of data loss from these artifacts, since dumps are daily. For tighter recovery, rely on Supabase PITR (Point-in-Time Recovery) on the Pro plan — it is the primary recovery mechanism for recent corruption. This artifact workflow is the offsite, version-controlled backup of last resort.
- RTO (Recovery Time Objective): dominated by restore time. A
psql < data-*.sqlon a fresh Supabase project typically takes tens of minutes; schema-only restore is minutes.
Warning
Supabase PITR is separate from this workflow and lives in the Supabase dashboard under Database → Backups. PITR on Pro plans retains 7 days of WAL. Both mechanisms are complementary: PITR for recent bugs, artifact backups for long-horizon disaster recovery.
Prerequisites
A single GitHub Actions secret: SUPABASE_DB_URL — the direct connection Postgres URI (not pgbouncer). Set up steps in the parent doc.
Optional S3 archival secrets: AWS_S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION.
Triggering a manual backup
Before any risky migration or mass edit, kick off an on-demand backup.
UI: Actions → Supabase Backup → Run workflow → Run workflow
CLI:
Watch:
Downloading a backup
- GitHub → Actions tab → select the Supabase Backup workflow.
- Click the run for the date you want.
- Scroll to Artifacts → download
supabase-backup-YYYY-MM-DD.zip. - Unzip — you will have
schema-*.sql.gz,data-*.sql.gz,checksums.txt.
Verifying integrity
Always run this before restoring:
Both lines must report OK. If either fails, do not restore from that artifact — fetch another day or trigger a manual backup.
Restore walkthrough
Danger
Never restore into the production database unless this is an active disaster-recovery scenario authorised by CEO/GM. Set TARGET_DB_URL carefully; a typo pointing at prod will overwrite live data.
-
Decompress:
-
Set the target DB URL (use staging or a scratch Supabase project unless this is real DR):
-
Pick a restore mode:
Full restore (DR scenario):
# A new Supabase project grants anon / authenticated / service_role # everything on objects postgres creates in public. The dump records # production's grants relative to the built-in default only, so without # this every restored table and function gets grants production never had. psql "$TARGET_DB_URL" -c " ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON TABLES FROM anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon, authenticated, service_role;" psql "$TARGET_DB_URL" --single-transaction -v ON_ERROR_STOP=1 \ -f roles.sql -f schema.sql \ -c 'SET session_replication_role = replica' -f data.sqlThe dump ends by setting production's own default privileges, so the revoke above only affects objects created during the restore. A local Supabase stack whose auth or storage service is older than production's will refuse a few auth/storage tables and columns; the 2026-09-19 rehearsal lists them and how they were handled.
Schema-only (test a migration against last night's structure):
Data-only (re-seed a DB whose schema already matches):
-
Sanity checks after restore:
- Row counts on critical tables (
bookings,payments,customers,partners). - RLS policies are present (
SELECT polname FROM pg_policies WHERE schemaname='public';). - Verify the access model — see below. Not optional.
- A smoke test login as a real user succeeds.
- Trigger a Sentry release in the frontend pointing at the restored backend to verify end-to-end.
- Row counts on critical tables (
Verifying the access model after a restore
A restored database can look perfectly healthy — every table, every row, every
RLS policy — and still hand nobody any permissions. Roles and grants live in
Role, Permission and RolePermission, and those tables have their own
failure mode: every grant migration seeds with
so a missing Role row makes the join match nothing and the grant is skipped
without an error. Until 20260921110000 only two of the twenty roles were
created by a migration at all, and a restore into an empty project produced 2
roles and 204 grants instead of 20 and ~949. Row counts and policy checks do
not catch that; a login smoke test only catches it for the one user you tried.
Run the access-model suite against the restored database:
It must print ALL ACCESS MODEL TESTS PASSED. It asserts that every
RoleType value has a Role row, that every permission documented in
PERMISSIONS.md §4 exists, that SUPER_ADMIN holds all
of them, that the CEO/GM, IT_ADMIN, ADMIN_HR and CASHIER bundles still match
§5, that a role with no grants passes no check, and that the row counts have
not fallen off a cliff.
If it fails, do not hand the restored environment to users. Re-run the
migrations (supabase db push) so 20260921110000 seeds the roles and
grants, then run the suite again.
access_model.sql does not check table and function grants. It passed on
a restore that let anonymous callers run get_customer_360 and let a partner
login rename a permission. Run the grant suites too, and treat any failure as a
stop:
DB_URL="$TARGET_DB_URL" scripts/db-test.sh supabase/tests/security_lockdown.sql \
supabase/tests/data_isolation.sql supabase/tests/journey.sql \
supabase/tests/list_paging.sql supabase/tests/finance_controls.sql \
supabase/tests/audit_trail.sql
Security notes
- GitHub Actions artifacts are scoped to repository collaborators with read access. Treat their contents as production credentials.
data-*.sqlcontains every customer record, payment detail, partner rate. Do not download to a personal machine; restore directly into a controlled environment.- Rotate
SUPABASE_DB_URLwhenever a collaborator with admin access leaves the team (seesecurity.md). - The dump itself does not contain the database password — only the workflow run's environment does, and GitHub redacts it from logs.
See docs/BACKUP_AND_RESTORE.md for the full source of truth (first-time secret setup, S3 rotation, retention rationale).