Back up a Supabase project to your own S3, R2 or B2 bucket with the open-source backupdrill CLI, run a restore drill to prove the snapshot restores, and restore into a new project. Includes plain pg_dump and pg_restore notes for Supabase and what the Free plan does and doesn't back up.
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Read this when the user asks what Supabase already backs up for them, wants a plain pg_dump backup instead of the CLI, or wants to test a restore by hand. The commands here are what backupdrill backup and backupdrill drill automate.
supabase db dump) and keep off-site copies. Free projects are paused after a week of inactivity; the pause snapshot lets the user restore the paused project from the dashboard for up to a year.storage.objects rows, which only point at files. The files themselves need a separate copy (the CLI copies them once the Storage S3 keys are set).Every backup taken from outside the project counts as egress on the user's Supabase bill. Free includes 5 GB a month, shared with whatever the app already serves. Run backupdrill estimate --plan free for an upper-bound estimate of the project's backup egress. As a rough guide, if each run transfers 500 MB (the Free database limit), weekly backups use about 2 GB a month and daily ones about 15 GB, three times the allowance.
In the dashboard, Connect → Session pooler: port 5432, user postgres.<project-ref>. Copy the host from there as it is; it can't be worked out from the region. Without the IPv4 add-on, the direct connection needs IPv6, so on an IPv4-only home or CI network it won't connect. Don't use port 6543, the transaction pooler: pg_dump needs a session.
pg_dump's major version must be at least the server's. Supabase runs Postgres 15 to 17, so a version 17 client covers all of them. Check with pg_dump --version.pg_restore must be at least as new as the pg_dump that wrote the archive. Archives from pg_dump 18 can contain SQL that Postgres 17 rejects, so stay on 17 unless the project runs something newer.Keep the password off the command line, where other users on the machine can see it in the process list. Ask the user to add one line to ~/.pgpass themselves and make the file private (chmod 600 ~/.pgpass); pg_dump, pg_restore and psql read it, so the connection string carries no password:
<pooler-host>:5432:postgres:postgres.<project-ref>:<password>In that file a : or \ inside the password has to be written as \: or \\.
export DB_URL="postgresql://postgres.<project-ref>@<pooler-host>:5432/postgres?sslmode=require"
export BACKUP_FILE="backup-$(date +%Y%m%dT%H%M%S).dump"
# umask 077 in a subshell: the dump is production data, so only the user can read it
( umask 077 && pg_dump "$DB_URL" --format=custom --schema=public --file="$BACKUP_FILE" ) \
&& pg_restore --list "$BACKUP_FILE" > /dev/null && echo "archive is readable"sslmode=require encrypts the connection. To also check the server's certificate, download the CA certificate from the project's database settings (SSL Configuration) and use sslmode=verify-full&sslrootcert=<path to the certificate>.--format=custom writes an archive that only pg_restore reads; it allows selective and parallel restores. A plain-text dump goes through psql instead.--schema=public leaves out Supabase-managed schemas, including auth, so Auth users aren't in this file. Add --schema=<name> for each of the app's own schemas.--schema, pg_dump also leaves out large objects (the lo_* / oid kind, not Storage files). If the app uses them, add --large-objects..env.backupdrill as SKILL.md describes.A dump that has never been restored is a guess. Restore it into a throwaway Postgres and look at the data. The container will hold a copy of production data, and every account on the machine can reach a port on 127.0.0.1, so give it a new random password. Start the container and wait until it accepts connections:
TEST_PW="$(openssl rand -hex 16)"
# docker run can print an ID and still fail (port taken), so only a zero exit counts.
# -e with no value makes docker read the password from its own environment, not from its arguments.
cid="$(POSTGRES_PASSWORD="$TEST_PW" docker run -d --rm -e POSTGRES_PASSWORD -p 127.0.0.1:55432:5432 postgres:17-alpine)" || cid=""
[ -n "$cid" ] \
&& for i in $(seq 60); do docker exec "$cid" pg_isready -q -h 127.0.0.1 -U postgres && break; sleep 1; done \
&& docker exec "$cid" pg_isready -h 127.0.0.1 -U postgres
echo "container: ${cid:-not started}"If it prints container: not started (port 55432 already taken, for example) or the last pg_isready says no response, stop and fix that first. Then restore and check:
export TEST_DB="postgresql://postgres@127.0.0.1:55432/postgres"
# PGPASSWORD goes on each command, not exported: exported, it would also replace
# ~/.pgpass for the production connection in this shell.
# The dump creates schema public itself, so drop the empty one first. Then add stand-ins
# for what a Supabase project provides, so policies that mention them restore.
# --no-privileges skips GRANT and REVOKE: this test checks the data, not who can read it.
PGPASSWORD="$TEST_PW" psql "$TEST_DB" --set ON_ERROR_STOP=1 -c "
drop schema public;
create role anon; create role authenticated; create role service_role;
create schema auth;
create function auth.uid() returns uuid language sql stable as 'select null::uuid';
create function auth.role() returns text language sql stable as 'select null::text';
create function auth.email() returns text language sql stable as 'select null::text';
create function auth.jwt() returns jsonb language sql stable as 'select null::jsonb';" \
&& PGPASSWORD="$TEST_PW" pg_restore --no-owner --no-privileges --dbname "$TEST_DB" "$BACKUP_FILE"
PGPASSWORD="$TEST_PW" psql "$TEST_DB" -c "select count(*) from pg_tables where schemaname = 'public';"
PGPASSWORD="$TEST_PW" psql "$TEST_DB" -c "select count(*) from public.<a table that must never be empty>;"When you're done, remove only the container you started: docker rm -f "$cid".
pg_restore keeps going after an error and prints how many it ignored at the end. Read every error. The one expected failure is a foreign key to auth.users (a profiles table usually has one): this dump has no Auth users, so the constraint can't be created here. Anything else is a failed test.max(created_at) shows how recent the backup is.backupdrill backup runs the same pg_dump, copies Storage files, and writes a checksummed manifest.json and a RECOVERY.md next to the dump in the user's bucket. backupdrill drill does the restore test above against the latest snapshot in that bucket and reports each check. The full command reference is at backupdrill.com/docs/cli.