Free-tier Supabase has no automatic backups. If you fat-finger a DELETE or your project gets paused and purged, the data is gone. So I wired up a GitHub Actions cron job that dumps the Postgres database every night and ships it to a Cloudflare R2 bucket — for essentially zero dollars. Here’s the whole thing, plus the three dumb mistakes that cost me an hour.
TL;DR
A single YAML file dumps the database nightly with the Supabase CLI and uploads it to Cloudflare R2 (S3-compatible, no egress fees). Cost: GitHub Actions free minutes + R2’s free tier. No servers, no cron box. Setup is ~30 minutes, most of it the three gotchas below.
Why this exists
I’m building seelect, a real-time AR nail try-on app, with Supabase as the backend. On the free plan you get a great Postgres database — but open Database → Backups in the dashboard and you’ll find nothing. Daily backups start on Pro ($25/mo); Point-in-Time Recovery is a further paid add-on.
For a side project that isn’t earning yet, $25/mo just for backup insurance is hard to justify. But running with zero backups is reckless — the data (a curated product catalog) took real effort to build. So: roll your own.
The plan:
- Dump the database on a schedule with the Supabase CLI.
- Push the dump to storage that lives outside Supabase (so if anything happens to the account, the backups survive).
- Use Cloudflare R2 — it’s S3-compatible and has no egress fees, so restoring costs nothing to download.
The whole thing
One workflow file:
# .github/workflows/db-backup.yml
name: DB Backup
on:
schedule:
- cron: "0 3 * * *" # daily at 03:00 UTC
workflow_dispatch: {} # manual run button
concurrency:
group: db-backup
cancel-in-progress: false
jobs:
backup:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Install Supabase CLI
uses: supabase/setup-cli@v1
with:
version: latest
- name: Dump database
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
run: |
set -euo pipefail
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
echo "STAMP=$STAMP" >> "$GITHUB_ENV"
echo "YEAR=$(date -u +%Y)" >> "$GITHUB_ENV"
mkdir -p dump
# Default dump is schema-only, so take three explicit passes:
supabase db dump --db-url "$SUPABASE_DB_URL" --role-only -f dump/roles.sql
supabase db dump --db-url "$SUPABASE_DB_URL" -f dump/schema.sql
supabase db dump --db-url "$SUPABASE_DB_URL" --data-only --use-copy -f dump/data.sql
tar -czf "backup-${STAMP}.tar.gz" -C dump roles.sql schema.sql data.sql
- name: Upload to Cloudflare R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
aws s3 cp "backup-${STAMP}.tar.gz" \
"s3://${R2_BUCKET}/backups/${YEAR}/backup-${STAMP}.tar.gz" \
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \
--only-show-errors
A few notes on why it looks like this:
supabase db dump --db-urlneeds neither Docker nor a linked project — the CLI bundles the rightpg_dumpversion, dodging the classic “server version mismatch” pain.- Three passes, because a default dump is schema-only. You need
--role-only, the default (schema), and--data-onlyseparately for a complete backup. aws-cliis preinstalled onubuntu-latest, and R2 speaks the S3 API — point--endpoint-urlat R2 and use it like S3. Region is the literal stringauto.- Retention lives in an R2 lifecycle rule (bucket → Settings → Object lifecycle rules → delete after N days), not the workflow. Let the storage layer prune.
The three gotchas that cost me an hour
The concept is trivial; the setup has three sharp edges.
1. You MUST use the Session Pooler connection string
My first run died with:
connection to server at "db.<ref>.supabase.co" (2a05:d014:...), port 5432 failed:
Network is unreachable
That 2a05:... address is IPv6. Supabase’s direct connection host (db.<ref>.supabase.co) is IPv6-only now — and GitHub Actions runners are IPv4-only. They can’t reach it.
The fix is the Session Pooler connection string, which is reachable over IPv4. In the dashboard: Connect → Session pooler → URI. Tell the three modes apart at a glance:
| Mode | Host | Port | User | Works on CI? |
|---|---|---|---|---|
| Direct | db.<ref>.supabase.co | 5432 | postgres | ❌ IPv6-only |
| Session pooler | ...pooler.supabase.com | 5432 | postgres.<ref> | ✅ |
| Transaction pooler | ...pooler.supabase.com | 6543 | postgres.<ref> | ❌ no pg_dump |
The transaction pooler (6543) is also a trap — it doesn’t support pg_dump. You want the session pooler on 5432, where the username is postgres.<project-ref>.
Two more things: the password isn’t retrievable after project creation (reset it under Settings → Database → Reset database password — safe, since your app auth uses the anon key, not the DB password), and special characters in the password must be percent-encoded (@ → %40, etc.). Generate a letters-and-digits password and skip that headache.
2. The R2 “Access Key ID” is NOT the token value
Past the DB, the upload failed with:
An error occurred (InvalidArgument) when calling the PutObject operation:
Credential access key has length 53, should be 32
R2’s terminology is confusing. When you create an Account API Token, the page shows a “Token value” (a long Bearer token) — but that is not your S3 credential. For S3-compatible tools you need two different values, with strict lengths:
- Access Key ID — exactly 32 hex chars (the token’s id)
- Secret Access Key — exactly 64 hex chars (the SHA-256 of the token value)
I’d pasted the ~53-char Token value into R2_ACCESS_KEY_ID. Wrong field. Look for the “Use the following credentials with any S3-compatible tool” block on the token-creation screen — that’s where the real keys live. (If you kept only the token value, you can derive the secret with printf '%s' '<token-value>' | sha256sum, but you can’t derive the 32-char access key id — just roll the token and copy both.)
3. The Account ID hides in the endpoint URL
Minor, but it confused me. Your R2 endpoint looks like:
https://71a8d268cfe643558bfd78496cdf1e6d.r2.cloudflarestorage.com/my-bucket
└───────────── account id ─────────────┘ └ bucket ┘
The subdomain is your account id — that’s R2_ACCOUNT_ID (not secret; it’s in every request URL anyway).
Setup checklist
- R2: create a bucket → create an Account API Token (Object Read & Write) → copy the 32-char Access Key ID and 64-char Secret → add a lifecycle rule for retention.
- Supabase: Connect → Session pooler → copy the URI, replace
[YOUR-PASSWORD]with your DB password. - GitHub repo secrets:
SUPABASE_DB_URL,R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET. - Commit the workflow, merge to your default branch (cron and the manual-run button only work from the default branch), then hit Actions → DB Backup → Run workflow to test.
Restoring (and please test it)
A backup you’ve never restored is a rumor, not a backup. Download a tarball (free egress, thanks R2), unpack, and replay into a fresh database in order:
aws s3 cp "s3://<bucket>/backups/<year>/backup-<stamp>.tar.gz" . \
--endpoint-url "https://<account-id>.r2.cloudflarestorage.com"
tar -xzf backup-<stamp>.tar.gz
psql "$TARGET_DB_URL" -f roles.sql # harmless warnings if roles exist
psql "$TARGET_DB_URL" -f schema.sql
psql "$TARGET_DB_URL" -f data.sql
Spin up a throwaway Supabase project, run that once, confirm your tables come back. Do it now, not during an incident.
What this doesn’t cover
- Supabase Storage objects (files in buckets).
pg_dumpdoesn’t touch them. I have none, so I skipped it — if you do, sync them separately withrcloneor the S3 API. - Failure alerts. A failed run is just a red X in the Actions tab. A natural next step is a Slack/Telegram ping on failure so silent breakage doesn’t pile up.
- Point-in-time recovery. This is daily snapshots, not WAL-level PITR. For second-level recovery, that’s Supabase Pro + the PITR add-on, or self-managing something like
wal-g.
Why I like it
It’s boring, and that’s the point. No infrastructure to babysit, no monthly bill, the backups live with a different vendor than the database, and the whole thing is one reviewable YAML file in version control. For an early-stage project that needs a safety net rather than enterprise DR, this hits the sweet spot.
If you ship it, go run a test restore today. Future-you will be grateful.
Built this while working on seelect — a real-time AR nail try-on app for iPhone.