Postgres backups, restore-tested · checked

Supabase's Free plan has no backups

Supabase's Free plan includes no automatic backups: its docs tell Free projects to export their data with supabase db dump and keep the copies elsewhere. Pro keeps 7 days of daily backups, Team 14 and Enterprise 30, and point-in-time recovery is an add-on from about $100 a month. No plan's backups hold the files in Supabase Storage, only their metadata.

Free
No automatic backups.
Pro
Daily backups, kept 7 days.
Team
Daily backups, kept 14 days.
Enterprise
Daily backups, kept 30 days.
Point-in-time recovery
An add-on on Pro, Team and Enterprise: about $100 a month for 7 days of history, $200 for 14 and $400 for 28.
Storage files
Not in any backup: backups hold the metadata of stored objects, not the files.

Supabase's own GitHub Actions recipe dumps the roles, schema and data every day and commits the three files to the repository. The same page says never to back up to a public repository, and nothing in the recipe restores what it saved.

From GitHub Actions, use the Session pooler connection string (port 5432 on *.pooler.supabase.com). The direct db.<ref>.supabase.co host is IPv6 only unless the project has the IPv4 add-on, and GitHub's hosted runners have no IPv6. The transaction pooler (port 6543) does not support prepared statements and cannot take a whole-database dump.

Test a backup by hand

Set DATABASE_URL to the Session pooler string (Connect > Session pooler in the Supabase dashboard). Run these where PostgreSQL's own programs are installed, of the server's version or newer; they need nothing from us and send nothing anywhere.

# 1. back up, without Supabase's platform schemas
pg_dump --format=custom --no-owner --no-privileges --file=backup.dump \
  --exclude-schema='graphql*' --exclude-schema=vault --exclude-schema='pgsodium*' \
  --exclude-schema=net --exclude-schema=cron --exclude-schema='*realtime' \
  --exclude-schema=_analytics --exclude-schema=_supavisor --exclude-schema=pgbouncer \
  --exclude-schema=pgtle "$DATABASE_URL"

# 2. restore into a scratch database on a local PostgreSQL of the same major version,
#    with the roles Supabase's policies name, and without the extensions only Supabase has
createdb restore_test
for r in anon authenticated service_role; do createuser --no-login "$r" || true; done
pg_restore -l backup.dump | grep -v -E 'EXTENSION (- )?(pg_graphql|supabase_vault|pgsodium|pg_net|pg_cron|pgjwt) ' > restore.list
pg_restore --no-owner --no-privileges --exit-on-error --use-list=restore.list --dbname=restore_test backup.dump

# 3. count every table on both sides, and compare
COUNT="SELECT format('SELECT %L, count(*) FROM %I.%I;', schemaname || '.' || relname, schemaname, relname) FROM pg_stat_user_tables WHERE schemaname NOT IN ('vault', 'net', 'cron', 'realtime', '_realtime', '_analytics', '_supavisor', 'pgbouncer', 'pgtle') AND schemaname NOT LIKE 'graphql%' AND schemaname NOT LIKE 'pgsodium%' ORDER BY 1"
psql "$DATABASE_URL" -XAtc "$COUNT" | psql "$DATABASE_URL" -XAt > live.txt
psql -d restore_test -XAtc "$COUNT" | psql -d restore_test -XAt > restored.txt
diff live.txt restored.txt && echo "every table came back with the same number of rows"

--exit-on-error makes the restore stop at its first error instead of carrying on past it. A role that a row-level security policy names must exist first (createuser --no-login <role>), and so must every extension the backup creates (pgvector and PostGIS are packages of their own). The counts can differ by the rows written while the backup ran. This compares rows only; restoreproof also compares every column, constraint, index, function, trigger and policy.

restoreproof: the same test, every week

restoreproof backs the database up in your own GitHub Actions, restores the backup into a throwaway PostgreSQL of the same version on the runner, and fails the job when it would not restore. Each run checks:

A recorded run, and what else exists.

An email when a weekly restore test fails or does not run is not built.

It would email the people you name when a weekly test fails, when the backup shrinks, or when no test has run for eight days, from outside GitHub's scheduler. Today GitHub emails one person when a scheduled run fails, and nobody when it never runs: it can drop a scheduled run under load, and it turns off a public repository's schedules after 60 days without activity. agentcheck's free hourly watch covers MCP servers only today. If you would pay for this one, say so with one click. The click is counted; nothing else is sent or stored.

Sources

Related

restoreproof is a free tool from agentcheck, which runs scheduled checks of your endpoints and alerts you when an answer changes. Facts come from each provider's own pages, read on the day shown.