Skip to content

Cloudflare R2 Supabase Backup Guide

This guide configures a nightly Supabase PostgreSQL backup from GitHub Actions into Cloudflare R2.

Target behavior:

  • Backup runs every day at 12:00 AM Asia/Manila / Asia/Singapore time.
  • Backup is uploaded to a private Cloudflare R2 bucket.
  • Only the latest 5 backups are kept.
  • Older backups are deleted automatically by the workflow.
  • Backup file uses PostgreSQL custom dump format: .dump.

Cloudflare R2 is separate from Cloudflare Domains and Pages. Having a domain or Pages project in Cloudflare does not automatically create R2 storage. You still need to enable R2 and create a bucket.


  1. Log in to the Cloudflare dashboard.
  2. Select your Cloudflare account.
  3. In the left menu, open R2 Object Storage.
  4. Enable R2 if it is not enabled yet.

Cloudflare’s docs say you must enable/purchase R2 before generating R2 API tokens. R2 has a free tier, but usage beyond the free tier can be billed.

Current free tier from Cloudflare R2 docs:

Storage: 10 GB-month per month
Class A operations: 1 million requests per month
Class B operations: 10 million requests per month
Egress: free

For database backups, use Standard storage, not Infrequent Access. The R2 free tier applies to Standard storage.


  1. Go to R2 Object Storage.
  2. Click Create bucket.
  3. Suggested bucket name:
obaki-supabase-backups
  1. Keep the bucket private.
  2. Do not connect a public custom domain.
  3. Do not enable public access.

This bucket should not be publicly readable. These backups may contain private application data.


  1. Open the Cloudflare dashboard.
  2. Select your account.
  3. Look for Account ID in the account details area.
  4. Save it temporarily.

You need this value for the R2 S3-compatible endpoint:

https://<ACCOUNT_ID>.r2.cloudflarestorage.com

Example shape:

https://abc123def4567890abc123def4567890.r2.cloudflarestorage.com

  1. Go to R2 Object Storage.
  2. Under account details, open Manage R2 API Tokens.
  3. Create an API token.
  4. Use an account-level token or user-level token.
  5. Scope it to your backup bucket if Cloudflare allows bucket scoping in your account.
  6. Give the token object read/write permission for the backup bucket.

Minimum permissions needed by this workflow:

Object Read
Object Write
Object Delete
List bucket objects

The workflow needs delete/list permission because it removes old backups after upload.

After creating the token, Cloudflare shows:

Access Key ID
Secret Access Key

Copy both immediately. The secret key cannot be viewed again later.


The workflow needs a PostgreSQL connection string, not the Supabase anon key, not the service role key, and not the project API URL.

Correct secret:

SUPABASE_DB_URL

Wrong secrets for pg_dump:

SUPABASE_URL
SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY
JWT_SECRET

  1. Open the Supabase dashboard.
  2. Open your project.
  3. Click Connect.
  4. Choose a PostgreSQL connection string.

For backups from GitHub Actions, use one of these:

Use this if it works from GitHub Actions or if your Supabase project has IPv4 support.

Shape:

postgresql://postgres:<YOUR-PASSWORD>@db.<PROJECT-REF>.supabase.co:5432/postgres

Use this if direct connection fails due to IPv6 or network issues.

Shape:

postgresql://postgres.<PROJECT-REF>:<YOUR-PASSWORD>@aws-0-<REGION>.pooler.supabase.com:5432/postgres

Use Session pooler, port 5432.

Do not use the transaction pooler on port 6543 for this backup workflow.


Supabase connection strings usually contain a placeholder:

[YOUR-PASSWORD]

Replace it with your actual database password.

If you do not know the database password:

  1. Open Supabase dashboard.
  2. Go to Database.
  3. Open Settings.
  4. Reset the database password.
  5. Update your app connection strings if your app uses the same password.
  6. Use the new password in SUPABASE_DB_URL.

If your password contains special URL characters, URL-encode them before putting them inside the connection string.

Common examples:

@ -> %40
# -> %23
? -> %3F
& -> %26
/ -> %2F
: -> %3A

Example:

Password: my@secure#password
Encoded: my%40secure%23password

Final connection string example:

postgresql://postgres:my%40secure%23password@db.abcdefghijklmnopqrst.supabase.co:5432/postgres

Open your GitHub repository:

Repository -> Settings -> Secrets and variables -> Actions -> New repository secret

Create these secrets:

SUPABASE_DB_URL
R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET

Example values:

SUPABASE_DB_URL=postgresql://postgres:<password>@db.<project-ref>.supabase.co:5432/postgres
R2_ACCOUNT_ID=<cloudflare-account-id>
R2_ACCESS_KEY_ID=<r2-access-key-id>
R2_SECRET_ACCESS_KEY=<r2-secret-access-key>
R2_BUCKET=obaki-supabase-backups

Do not commit these values into your repository.


Create this file in your repository:

.github/workflows/supabase-r2-backup.yml

Paste this workflow:

name: Supabase Database Nightly Backup
on:
workflow_dispatch:
schedule:
# Runs every day at 12:00 AM Asia/Manila / Asia/Singapore time.
- cron: '0 0 * * *'
timezone: 'Asia/Singapore'
permissions:
contents: read
jobs:
backup:
runs-on: ubuntu-latest
env:
KEEP_BACKUPS: 5
R2_BACKUP_PREFIX: supabase
AWS_DEFAULT_REGION: auto
steps:
- name: Install PostgreSQL client and AWS CLI
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y postgresql-client awscli
- name: Create PostgreSQL backup
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
run: |
set -euo pipefail
BACKUP_FILE="supabase-backup-$(date -u +%Y-%m-%d-%H%M%S).dump"
pg_dump "$SUPABASE_DB_URL" \
--format=custom \
--compress=9 \
--no-owner \
--no-acl \
--file="$BACKUP_FILE"
echo "BACKUP_FILE=$BACKUP_FILE" >> "$GITHUB_ENV"
- name: Upload backup to Cloudflare R2
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
R2_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
R2_URI="s3://${R2_BUCKET}/${R2_BACKUP_PREFIX}"
aws s3 cp "$BACKUP_FILE" "${R2_URI}/${BACKUP_FILE}" \
--endpoint-url "$R2_ENDPOINT"
- name: Keep only latest 5 backups
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
R2_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
R2_URI="s3://${R2_BUCKET}/${R2_BACKUP_PREFIX}"
mapfile -t BACKUPS < <(
aws s3 ls "${R2_URI}/" --endpoint-url "$R2_ENDPOINT" \
| awk '{ if ($4 ~ /^supabase-backup-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.dump$/) print $4 }' \
| sort
)
BACKUP_COUNT="${#BACKUPS[@]}"
echo "Found $BACKUP_COUNT backup(s). Keeping latest $KEEP_BACKUPS."
if [ "$BACKUP_COUNT" -le "$KEEP_BACKUPS" ]; then
echo "No old backups to delete."
exit 0
fi
DELETE_COUNT=$((BACKUP_COUNT - KEEP_BACKUPS))
for OLD_BACKUP in "${BACKUPS[@]:0:$DELETE_COUNT}"; do
echo "Deleting old backup: $OLD_BACKUP"
aws s3 rm "${R2_URI}/${OLD_BACKUP}" --endpoint-url "$R2_ENDPOINT"
done

GitHub Actions added timezone support for scheduled workflows in 2026. If your linter or GitHub rejects the timezone field, use the UTC fallback instead.

Asia/Manila and Asia/Singapore are UTC+8.

12:00 AM Asia/Manila/Singapore = 4:00 PM UTC on the previous day.

Fallback schedule:

on:
workflow_dispatch:
schedule:
- cron: '0 16 * * *'

Use only one schedule style, not both.


After committing the workflow:

  1. Go to your GitHub repository.
  2. Open Actions.
  3. Select Supabase Database Nightly Backup.
  4. Click Run workflow.
  5. Wait for the run to finish.
  6. Open Cloudflare R2.
  7. Open your bucket.
  8. Confirm this object exists:
supabase/supabase-backup-YYYY-MM-DD-HHMMSS.dump

Do not assume the backup works until you test restoring it.

Download one .dump file from R2, then restore to a separate test database.

Example restore command:

Terminal window
pg_restore \
--clean \
--if-exists \
--no-owner \
--no-acl \
--dbname="postgresql://postgres:<password>@<host>:5432/postgres" \
supabase-backup-YYYY-MM-DD-HHMMSS.dump

Never test restore directly against production unless you intentionally want to overwrite production data.


Your SUPABASE_DB_URL password is wrong, missing, or not URL-encoded correctly.

Fix:

  1. Reset the Supabase database password.
  2. Rebuild the connection string.
  3. Update GitHub secret SUPABASE_DB_URL.

Error: could not translate host name / network unreachable

Section titled “Error: could not translate host name / network unreachable”

Your direct Supabase database endpoint may be IPv6-only.

Fix:

Use the Session pooler connection string on port 5432.


Example:

server version: 17.x; pg_dump version: 16.x

Fix:

Install a PostgreSQL client version equal to or newer than your Supabase Postgres server version.


Your R2 token lacks permission.

Fix:

Create a new R2 token with bucket-scoped object read/write/delete/list permissions.


Your R2_BUCKET secret does not match the bucket name.

Fix:

Check the exact R2 bucket name and update the GitHub secret.


Use this checklist before relying on the workflow:

[ ] R2 bucket is private.
[ ] No public custom domain is connected to the backup bucket.
[ ] R2 token is scoped only to the backup bucket if possible.
[ ] GitHub secrets contain no extra spaces or quotes.
[ ] SUPABASE_DB_URL uses a database connection string, not anon/service role API keys.
[ ] Workflow manual run succeeds.
[ ] Backup appears in R2.
[ ] Retention keeps only 5 backups after repeated runs.
[ ] At least one restore test succeeds.