Restic to R2 plus binlog shipping: a restore drill in 24 s, RPO 119 s

Point Restic at an R2 bucket over the S3 API, ship MariaDB binlogs beside the nightly dump, and script the restore so it reports measured RTO and RPO.

KV/R2/D1

· Chapter

15

·

3

min read

The answer. R2 speaks S3, so Restic needs nothing but a bucket, a bucket-scoped R2 API token and a repository password: restic -r s3:<r2-endpoint>/<bucket>/production init. That gives you encrypted, deduplicated nightly base backups off the VPS at R2 prices. A nightly dump alone risks a day of loss, so also ship MariaDB's binary logs into the repository between dumps; a restore is then the latest base snapshot plus binlog replay to a --target-time. Then drill it: a script that restores into a disposable namespace, replays to the target, reconciles record counts against the backup manifest, and writes measured RTO and RPO seconds into a JSON report. If it isn't measured, it isn't a backup plan.

The pattern.

# backup.env (mode 0600) — separate repository password per environment
export RESTIC_REPOSITORY="s3:https://<account>.r2.cloudflarestorage.com/my-backups/production"
export RESTIC_PASSWORD_FILE=/opt/app/secrets/restic-password
export AWS_ACCESS_KEY_ID=…  AWS_SECRET_ACCESS_KEY=…     # R2 token scoped to this bucket only

# nightly (systemd timer): base dump + manifest with row counts
mariadb-dump --single-transaction --master-data=2 "$DB" | gzip > /backup/base.sql.gz
restic backup /backup --tag base
# every few minutes: ship closed binlogs (this interval bounds RPO)
mariadb -e 'FLUSH BINARY LOGS' && restic backup /var/lib/mysql/binlog.0* --tag binlog

# restore-drill.sh --target-time 2026-08-01T03:00:00Z  → disposable db, then a report
started=$(date -u +%s)
restic restore latest --tag base --target /restore
restic restore latest --tag binlog --target /restore/binlogs
gunzip -c /restore/backup/base.sql.gz | mariadb drill_db
mysqlbinlog --stop-datetime="$TARGET" /restore/binlogs/binlog.0* | mariadb drill_db
reconcile_counts drill_db /restore/backup/manifest.json || exit 1   # rows must match the manifest
jq -n --argjson rto $(( $(date -u +%s) - started )) --argjson rpo $(( started - binlog_snapshot_epoch )) \
      '{measured_rto_seconds: $rto, measured_rpo_seconds: $rpo}'

The receipt. On our LMS product's production VPS, summer 2026: nightly encrypted MariaDB dumps go to one R2 bucket under /staging and /production prefixes with separate Restic passwords, and binlogs are shipped for point-in-time recovery. The restore drill passed with a measured RTO of 24 s and RPO of 119 s, tenant record counts reconciled against the manifest. RPO is the age of the newest shipped binlog at drill start — bounded by the shipping interval, not the nightly dump. The drill script takes --target-time or --backup-id, restores into a disposable namespace and refuses to report success unless the count reconciliation returns ok: true.

Watch out.

  • Use a per-bucket R2 API token for Restic, never an account-wide one; the backup host is the likeliest thing to be compromised.
  • Separate repository passwords per environment: a staging leak must not open production snapshots.
  • A drill that only restores the base dump measures the wrong RPO. Replay the binlogs and record the binlog snapshot time in the report.

Related: r2-custom-domain-choose-once · wrangler-kv-r2-default-local-use-remote