Daily DB backup → Cloudflare R2 #87
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |