Writing

Free Supabase Backups to Cloudflare R2 with GitHub Actions

Free-tier Supabase has no automatic backups. Here's how to dump Postgres to Cloudflare R2 nightly with GitHub Actions — the full workflow and 3 setup gotchas.


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:

  1. Dump the database on a schedule with the Supabase CLI.
  2. Push the dump to storage that lives outside Supabase (so if anything happens to the account, the backups survive).
  3. 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:

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:

ModeHostPortUserWorks on CI?
Directdb.<ref>.supabase.co5432postgres❌ IPv6-only
Session pooler...pooler.supabase.com5432postgres.<ref>
Transaction pooler...pooler.supabase.com6543postgres.<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:

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

  1. 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.
  2. Supabase: Connect → Session pooler → copy the URI, replace [YOUR-PASSWORD] with your DB password.
  3. GitHub repo secrets: SUPABASE_DB_URL, R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET.
  4. 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

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.