Supabase Free tier has no backups. Here's a free fix in ten minutes
A GitHub Actions workflow that pg_dumps your Supabase database every night, encrypts it, uploads it to any S3-compatible bucket, and test-restores it — with no servers and no bill.
Free Supabase projects have no automated backups. Pro projects get seven days of daily snapshots inside Supabase. Either way, if you want a copy that lives somewhere else and that you know restores, you have to make it yourself.
This is the smallest setup we'd trust: a GitHub Actions workflow on a schedule. It runs pg_dump, encrypts the archive, uploads it to an S3-compatible bucket (Cloudflare R2's free tier is 10 GB and has no egress fees; AWS S3, Backblaze B2 or MinIO work the same), and then actually restores the dump into a throwaway Postgres to prove it's usable. Everything runs on GitHub's free minutes.
What you need
- Your database connection string from Project Settings → Database. Use the session pooler or the direct connection (port 5432). The transaction pooler on port 6543 cannot run
pg_dump. - A bucket and an access key that can write to it.
- A 32-byte passphrase for encryption:
openssl rand -base64 32.
Store these as repository secrets: SUPABASE_DB_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, BACKUP_PASSPHRASE.
The workflow
Save this as .github/workflows/backup.yml:
name: supabase-backup
on:
schedule:
- cron: "17 3 * * *" # 03:17 UTC nightly; avoid :00 to dodge the rush
workflow_dispatch:
jobs:
backup:
runs-on: ubuntu-latest
services:
# a throwaway Postgres for the restore test
pg:
image: postgres:17
env: { POSTGRES_PASSWORD: test }
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 20
steps:
- name: Install Postgres 17 client
run: |
sudo apt-get install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get install -y postgresql-client-17
- name: Dump
env: { DB: ${{ secrets.SUPABASE_DB_URL }} }
run: |
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
echo "STAMP=$STAMP" >> "$GITHUB_ENV"
pg_dump "$DB" --format=custom --compress=6 --no-owner --no-privileges \
--file "backup-$STAMP.dump"
ls -l backup-$STAMP.dump
- name: Restore test into throwaway Postgres
run: |
# Supabase-managed schemas exist only on Supabase; restore the rest.
pg_restore --no-owner --no-privileges --jobs 2 \
--exclude-schema=auth --exclude-schema=storage --exclude-schema=extensions \
--exclude-schema=realtime --exclude-schema=supabase_functions --exclude-schema=vault \
--exclude-schema=graphql --exclude-schema=graphql_public --exclude-schema=pgsodium \
--exclude-schema=net --exclude-schema=cron --exclude-schema=supabase_migrations \
--dbname "postgresql://postgres:test@localhost:5432/postgres" "backup-$STAMP.dump" || true
TABLES=$(psql "postgresql://postgres:test@localhost:5432/postgres" -tAc \
"select count(*) from information_schema.tables where table_schema='public'")
echo "restored public tables: $TABLES"
test "$TABLES" -gt 0 # fail the run if nothing came back
- name: Encrypt
env: { PASS: ${{ secrets.BACKUP_PASSPHRASE }} }
run: |
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt \
-in "backup-$STAMP.dump" -out "backup-$STAMP.dump.enc" -pass env:PASS
rm "backup-$STAMP.dump"
- name: Upload
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
run: |
aws s3 cp "backup-$STAMP.dump.enc" \
"s3://${{ secrets.S3_BUCKET }}/supabase/backup-$STAMP.dump.enc" \
--endpoint-url "${{ secrets.S3_ENDPOINT }}"
Run it once by hand from the Actions tab. If the restore step reports zero tables, fix that before trusting the schedule.
What this gives you
- A nightly, encrypted, off-site copy on any plan, including Free.
- A restore test on every run. This is the part most home-grown scripts skip, and it's the part that matters: a backup file that can't be restored is a very well-organised way to lose data.
- A failure email from GitHub when a run breaks.
What it doesn't give you
Be clear-eyed about the gaps, because they're the reason backup services exist:
- Storage buckets. Your users' files are not in the database. Add
aws s3 syncagainst Supabase's S3-compatible Storage endpoint if you need them, with the S3 access keys from Storage → S3 access keys. - Retention. The bucket grows forever until you add a lifecycle rule (R2, S3 and B2 all support "delete objects older than N days").
- Missed runs. GitHub silently skips scheduled runs on repositories with no activity for 60 days, and cron on shared runners can be delayed. Nothing tells you a backup didn't happen; you find out when you look.
- Row-level verification. "More than zero tables" catches total failure. It doesn't catch a table that restored with half its rows.
- Restore into Supabase. The test restores into vanilla Postgres. Restoring into a new Supabase project means dealing with the managed schemas, roles and extensions by hand.
- Key management. The passphrase is in GitHub secrets. Anyone who can edit the workflow can print it.
If that list is fine for your project, use the workflow. Seriously, use it; it beats nothing by a mile.
If it isn't, that list is roughly what VaultKeep adds: Storage sync, retention with crypto-shredding, missed-window alerts, weekly verification that compares row counts against a manifest, one-click restore into a fresh Supabase project, and a per-backup key wrapped by a master key the runner never stores. It's free for one project, and you can keep the GitHub workflow running alongside it. Two independent copies are better than one.
Want this without maintaining it?
VaultKeep does what this workflow does — encrypted, off-site, restore-tested every week — plus Storage buckets, retention, alerts and one-click restore. Free for one project, and you never touch YAML.
Start backing up free →