Skip to content

Daily DB backup → Cloudflare R2 #87

Daily DB backup → Cloudflare R2

Daily DB backup → Cloudflare R2 #87

Workflow file for this run

name: Daily DB backup → Cloudflare R2
# Phase 3 of paycraft-v2-production-readiness — replaces the prior weekly
# backup.yml. Runs every day at 02:00 UTC, dumps framework-supabase in
# custom format, uploads to Cloudflare R2 (free 10GB tier), prunes objects
# older than 30 days. RPO=24h, RTO=4h per docs/DR_RUNBOOK.md.
on:
schedule:
- cron: '0 2 * * *' # daily 02:00 UTC
workflow_dispatch: # manual trigger for ad-hoc backups
permissions:
contents: read
jobs:
backup:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install postgresql-client + awscli
# Use Postgres apt repository to get a version matching the Supabase DB
# (default Ubuntu repos lag behind; awscli is also more reliable from pip).
run: |
sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - >/dev/null 2>&1
sudo apt-get update
sudo apt-get install -y postgresql-client-17 || sudo apt-get install -y postgresql-client
pip install --quiet awscli
- name: Dump + gzip framework-supabase
env:
SUPABASE_DB_URL: ${{ secrets.SUPABASE_DB_URL }}
run: |
set -euo pipefail
# Use the postgresql-client-17 binary explicitly. Runner has an older
# /usr/bin/pg_dump (16.x) installed as a transitive dep; Supabase Cloud
# runs Postgres 17 and rejects version-mismatched clients.
PG_DUMP="/usr/lib/postgresql/17/bin/pg_dump"
[ -x "$PG_DUMP" ] || PG_DUMP=$(command -v pg_dump)
echo "Using: $PG_DUMP ($($PG_DUMP --version))"
ts=$(date -u +%Y%m%d-%H%M%S)
out="backup-${ts}.dump.gz"
"$PG_DUMP" --format=custom --no-owner --no-acl "${SUPABASE_DB_URL}" | gzip > "${out}"
du -h "${out}"
echo "BACKUP_FILE=${out}" >> "${GITHUB_ENV}"
- 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_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
AWS_DEFAULT_REGION: auto
run: |
set -euo pipefail
# Force path-style addressing — R2 + boto3 default to virtual-host
# style which hits SSL handshake failures on certain account IDs.
mkdir -p ~/.aws
cat > ~/.aws/config <<EOF
[default]
region = auto
s3 =
addressing_style = path
EOF
dest_prefix=$(date -u +%Y/%m/%d)
aws s3 cp "${BACKUP_FILE}" \
"s3://paycraft-backups/${dest_prefix}/${BACKUP_FILE}" \
--endpoint-url "${AWS_ENDPOINT_URL}"
- name: Prune R2 dumps older than 30 days
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
AWS_DEFAULT_REGION: auto
run: |
set -euo pipefail
cutoff=$(date -u -d '30 days ago' +%Y-%m-%d)
aws s3 ls s3://paycraft-backups/ --recursive --endpoint-url "${AWS_ENDPOINT_URL}" \
| awk -v c="${cutoff}" '$1 < c { print $4 }' \
| while read -r key; do
aws s3 rm "s3://paycraft-backups/${key}" --endpoint-url "${AWS_ENDPOINT_URL}"
done
- name: Notify on failure
if: failure()
run: |
echo "::error::Daily backup failed; see runbook docs/DR_RUNBOOK.md"
# Future: post to Slack via SLACK_WEBHOOK_URL secret