Cloudflare R2 Supabase Backup Guide
Cloudflare R2 Supabase Backup Guide
Section titled “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. Cloudflare R2 setup
Section titled “1. Cloudflare R2 setup”1.1 Enable R2
Section titled “1.1 Enable R2”- Log in to the Cloudflare dashboard.
- Select your Cloudflare account.
- In the left menu, open R2 Object Storage.
- 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 monthClass A operations: 1 million requests per monthClass B operations: 10 million requests per monthEgress: freeFor database backups, use Standard storage, not Infrequent Access. The R2 free tier applies to Standard storage.
1.2 Create a private R2 bucket
Section titled “1.2 Create a private R2 bucket”- Go to R2 Object Storage.
- Click Create bucket.
- Suggested bucket name:
obaki-supabase-backups- Keep the bucket private.
- Do not connect a public custom domain.
- Do not enable public access.
This bucket should not be publicly readable. These backups may contain private application data.
1.3 Get your Cloudflare Account ID
Section titled “1.3 Get your Cloudflare Account ID”- Open the Cloudflare dashboard.
- Select your account.
- Look for Account ID in the account details area.
- Save it temporarily.
You need this value for the R2 S3-compatible endpoint:
https://<ACCOUNT_ID>.r2.cloudflarestorage.comExample shape:
https://abc123def4567890abc123def4567890.r2.cloudflarestorage.com1.4 Create an R2 API token
Section titled “1.4 Create an R2 API token”- Go to R2 Object Storage.
- Under account details, open Manage R2 API Tokens.
- Create an API token.
- Use an account-level token or user-level token.
- Scope it to your backup bucket if Cloudflare allows bucket scoping in your account.
- Give the token object read/write permission for the backup bucket.
Minimum permissions needed by this workflow:
Object ReadObject WriteObject DeleteList bucket objectsThe workflow needs delete/list permission because it removes old backups after upload.
After creating the token, Cloudflare shows:
Access Key IDSecret Access KeyCopy both immediately. The secret key cannot be viewed again later.
2. Supabase database connection string
Section titled “2. Supabase database connection string”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_URLWrong secrets for pg_dump:
SUPABASE_URLSUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEYJWT_SECRET2.1 Find the connection string
Section titled “2.1 Find the connection string”- Open the Supabase dashboard.
- Open your project.
- Click Connect.
- Choose a PostgreSQL connection string.
For backups from GitHub Actions, use one of these:
Option A: Direct connection
Section titled “Option A: Direct connection”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/postgresOption B: Session pooler connection
Section titled “Option B: Session pooler connection”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/postgresUse Session pooler, port 5432.
Do not use the transaction pooler on port 6543 for this backup workflow.
2.2 Add your database password
Section titled “2.2 Add your database password”Supabase connection strings usually contain a placeholder:
[YOUR-PASSWORD]Replace it with your actual database password.
If you do not know the database password:
- Open Supabase dashboard.
- Go to Database.
- Open Settings.
- Reset the database password.
- Update your app connection strings if your app uses the same password.
- 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: -> %3AExample:
Password: my@secure#passwordEncoded: my%40secure%23passwordFinal connection string example:
postgresql://postgres:my%40secure%23password@db.abcdefghijklmnopqrst.supabase.co:5432/postgres3. GitHub repository secrets
Section titled “3. GitHub repository secrets”Open your GitHub repository:
Repository -> Settings -> Secrets and variables -> Actions -> New repository secretCreate these secrets:
SUPABASE_DB_URLR2_ACCOUNT_IDR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEYR2_BUCKETExample values:
SUPABASE_DB_URL=postgresql://postgres:<password>@db.<project-ref>.supabase.co:5432/postgresR2_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-backupsDo not commit these values into your repository.
4. GitHub Actions workflow
Section titled “4. GitHub Actions workflow”Create this file in your repository:
.github/workflows/supabase-r2-backup.ymlPaste 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" done5. If timezone field fails
Section titled “5. If timezone field fails”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.
6. Manual test
Section titled “6. Manual test”After committing the workflow:
- Go to your GitHub repository.
- Open Actions.
- Select Supabase Database Nightly Backup.
- Click Run workflow.
- Wait for the run to finish.
- Open Cloudflare R2.
- Open your bucket.
- Confirm this object exists:
supabase/supabase-backup-YYYY-MM-DD-HHMMSS.dump7. Restore test
Section titled “7. Restore test”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:
pg_restore \ --clean \ --if-exists \ --no-owner \ --no-acl \ --dbname="postgresql://postgres:<password>@<host>:5432/postgres" \ supabase-backup-YYYY-MM-DD-HHMMSS.dumpNever test restore directly against production unless you intentionally want to overwrite production data.
8. Troubleshooting
Section titled “8. Troubleshooting”Error: password authentication failed
Section titled “Error: password authentication failed”Your SUPABASE_DB_URL password is wrong, missing, or not URL-encoded correctly.
Fix:
- Reset the Supabase database password.
- Rebuild the connection string.
- 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.
Error: pg_dump version mismatch
Section titled “Error: pg_dump version mismatch”Example:
server version: 17.x; pg_dump version: 16.xFix:
Install a PostgreSQL client version equal to or newer than your Supabase Postgres server version.
Error: AccessDenied from R2
Section titled “Error: AccessDenied from R2”Your R2 token lacks permission.
Fix:
Create a new R2 token with bucket-scoped object read/write/delete/list permissions.
Error: NoSuchBucket
Section titled “Error: NoSuchBucket”Your R2_BUCKET secret does not match the bucket name.
Fix:
Check the exact R2 bucket name and update the GitHub secret.
9. Security checklist
Section titled “9. Security checklist”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.10. References
Section titled “10. References”- Cloudflare R2 pricing: https://developers.cloudflare.com/r2/pricing/
- Cloudflare R2 API tokens: https://developers.cloudflare.com/r2/api/tokens/
- Cloudflare R2 S3 API compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- Supabase database connection docs: https://supabase.com/docs/guides/database/connecting-to-postgres
- Supabase automated backups docs: https://supabase.com/docs/guides/deployment/ci/backups
- GitHub Actions timezone support announcement: https://github.blog/changelog/2026-03-19-github-actions-late-march-2026-updates/
