This runbook owns the daily off-provider logical database backup in
.github/workflows/db-backup.yml. It complements the complete database and
private-Storage restore drill in openadapt-cloud/docs/RUNBOOK_DATA_SAFETY.md.
For an operator-controlled recovery point before the hosted prerequisites are ready, use the local capture and verification command. That path keeps the existing workflow alerts open and makes no scheduled or off-device recovery claim.
The design target is:
- one logical database backup each day;
- client-side
ageencryption before network transfer; - a private S3 bucket in OpenAdapt AWS account
992382684924; - a maximum recovery-point objective (RPO) of 24 hours when consecutive jobs succeed;
- 90-day S3 retention;
- a local restore to a separate Supabase scratch project;
- measured database-only RPO and recovery-time objective (RTO) evidence; and
- the separate Cloud drill before any complete recovery claim.
This is not yet a proven recovery path. A read-only check on 2026-08-18 used
AWS account 992382684924 and confirmed that the
openadapt-production-db-backup CloudFormation stack does not exist. GitHub
issue #126 records
the matching protected-environment configuration failure. As of that check:
- no daily backup has completed;
- no scratch restore has completed;
- no measured RTO exists;
- provider PITR is not enabled;
- the AWS stack is not deployed;
mainhas no repository ruleset or branch protection;- no workflow-restricted production backup runner group or runner is confirmed;
- the
production-backupGitHub environment has none of its four required settings and has no deployment-branch restriction; - the
production-backup-monitorGitHub environment is not configured; - no scratch Supabase project is configured; and
- one local private
agekey exists, but its required second vault or offline copy is not confirmed.
Do not describe the database as recoverable until one scheduled backup and one isolated restore drill pass.
This repository is public. Production database bytes must never enter a GitHub artifact, workflow log, pull-request attachment, or public release.
The workflow writes only these objects to a private S3 bucket:
daily/<UTC-stamp>/db-backup-<UTC-stamp>.tar.gz.age, which isageciphertext; anddaily/<UTC-stamp>/artifact-manifest.json, which contains sizes, digests, the source commit, and the workflow run ID. It contains no project name, database URL, table data, or credential.
S3 also encrypts each object with SSE-S3. This server-side layer does not
replace age. The private age key stays on a trusted operator device and in
a separate vault or offline copy. It does not enter GitHub.
The logical database backup does not contain private Supabase Storage objects. It also uses the maintained Supabase roles, schema, and data commands, which take separate logical snapshots. Do not run it during a schema migration. The complete Cloud drill pauses writes, exports and rechecks Storage, restores both boundaries, and produces the canonical retention receipt.
Create the external GitHub gates before the CloudFormation stack creates an OIDC role. The OIDC subject binds a role to an environment. The environment's deployment policy is the exact branch gate.
- Protect
mainwith a repository ruleset. Require a pull request and the applicable status checks. Add required code-owner review only when a second authorized maintainer can approve the founder's pull request. - Create the
production-backupGitHub environment. - Give it one custom deployment branch policy: the exact
mainbranch. Do not select every protected branch. Do not add a tag or wildcard policy. - Create the
production-backup-monitorGitHub environment. - Give it the same single custom
mainbranch policy. - Create an organization runner group named
production-backup. Permit only this repository. Restrict it toOpenAdaptAI/openadapt-ops/.github/workflows/db-backup.yml@refs/heads/main. Do not put the runner in the default group and do not permit another workflow to use the group. - Register an ephemeral Linux runner inside the declared OpenAdapt production
boundary and put it only in the
production-backupgroup. Use a clean encrypted work volume for each job and remove the runner after the job. Permit network access only to GitHub Actions, the exact production Supabase database endpoints, and the private AWS backup target. - Do not require a manual environment approval. An approval wait would prevent the scheduled jobs.
Verify that main reports as protected. Verify that each environment reports
custom_branch_policies: true, protected_branches: false, and one policy with
the exact name main. Both workflows repeat this check after the environment
admits the job and before they request AWS credentials. The job has read-only
Actions permission for this API check. Do not deploy the AWS stack until this
gate passes. Also verify that the runner group is restricted to the exact
workflow and that the selected runner reports self-hosted. A self-hosted
runner attached to this public repository without the exact workflow
restriction is unsafe because pull-request code can target it.
The CloudFormation template creates:
- a private, versioned S3 bucket;
- public-access blocking and a TLS-only bucket policy;
- 90-day retention for daily backups;
- 365-day retention for database-only drill evidence;
- a GitHub OIDC writer role bound to the exact
production-backupenvironment; - a read-only GitHub OIDC monitor role bound to the exact
production-backup-monitorenvironment; and - a local restore role bound to one exact AWS operator principal.
The expected S3 Standard storage price is approximately USD 0.023 per GB each month, plus small request charges. For example, retaining 90 daily 100 MB backups is approximately 9 GB, or USD 0.21 each month before requests. The template does not create a paid KMS key and does not enable Supabase PITR.
The backup workflow uses one S3 PutObject with a caller-supplied full-object
SHA-256. S3 validates that checksum before it accepts the object. This launch
path refuses an encrypted archive above 5 GiB before upload. Build and qualify a
multipart contract before a production database can exceed that limit.
The writer, monitor, and restore paths read the live bucket controls before
they use an object. They require AWS account 992382684924, region
us-east-1, complete public-access blocking, SSE-S3, versioning,
bucket-owner-enforced ownership, the exact 90-day and 365-day lifecycle rules,
TLS-only transport, and the exact encryption policy. A drifted target stops the
operation before database access or recovery-point selection.
An AWS principal with CloudFormation, IAM, and S3 administration rights must run:
export AWS_PROFILE=openadapt
aws sts get-caller-identity --query Account --output text
# Must print 992382684924.
aws cloudformation deploy \
--stack-name openadapt-production-db-backup \
--template-file ops/backup/aws-backup-target.yml \
--capabilities CAPABILITY_NAMED_IAM \
--tags owner=OpenAdapt environment=production purpose=database-backupThe current claude-ops principal cannot create this stack because it does
not have cloudformation:CreateChangeSet. Do not bypass the template with
manual S3 or IAM changes. Use an authorized administration principal, then
record these outputs:
aws cloudformation describe-stacks \
--stack-name openadapt-production-db-backup \
--query 'Stacks[0].Outputs'The branch and environment gates already exist. Add the AWS outputs and the database identity only after the stack passes validation:
- Set
production-backupenvironment variables from the CloudFormation outputs:AWS_BACKUP_BUCKETAWS_BACKUP_ROLE_ARN
- Set
production-backupenvironment secrets:SUPABASE_DB_URL: the production direct or session-pooler PostgreSQL URLSUPABASE_PROJECT_REF: the exact production project reference
- Set
production-backup-monitorenvironment variables from the CloudFormation outputs:AWS_BACKUP_BUCKETAWS_BACKUP_MONITOR_ROLE_ARN
The monitor environment has no secret. Its AWS role can list and inspect only
the daily/ objects. It cannot create, replace, delete, download, or decrypt a
database backup.
The workflow validates that the URL belongs to the declared Supabase project.
It also checks AWS account 992382684924, complete S3 public-access blocking,
and the committed age recipient before it reads the database.
The dump job does not use a GitHub-hosted runner. The complete database exists
in plaintext on the isolated backup runner until local age encryption. The
cleanup trap removes the plaintext before the runner is destroyed. The
freshness monitor can use a GitHub-hosted runner because it reads only the
redacted manifest and S3 object metadata. It cannot read the ciphertext.
The public recipient is in ops/backup/age-recipients.txt. Store its private
key with mode 0600 on an encrypted trusted device. Make a second copy in a
team vault or an offline medium before the first backup. Without a second copy,
one device loss makes all retained backups unreadable.
After the AWS and GitHub setup, dispatch the workflow once. Do not wait for the next schedule.
gh workflow run db-backup.yml --repo OpenAdaptAI/openadapt-ops --ref main
gh run list --repo OpenAdaptAI/openadapt-ops \
--workflow db-backup.yml --limit 5
gh workflow run db-backup-freshness.yml \
--repo OpenAdaptAI/openadapt-ops --ref main
gh run list --repo OpenAdaptAI/openadapt-ops \
--workflow db-backup-freshness.yml --limit 5Require all of these results:
- the workflow succeeds on the exact
maincommit; - S3 contains one ciphertext object and one redacted manifest below the same UTC stamp;
- the stored SHA-256 checksum equals the local upload checksum;
- S3 reports the exact caller-supplied full-object SHA-256 for the ciphertext;
- the encrypted archive is no more than the enforced 5 GiB launch limit;
- no GitHub Actions artifact exists for the run.
- the separate read-only freshness workflow selects the same recovery point, validates the redacted manifest digest, matches the S3 object size and remote checksum, and reports an age of less than 24 hours.
A successful upload proves backup creation and storage. It does not prove that the backup can restore.
Create a new disposable Supabase project in the required region. Never use the production project, an existing customer project, or a shared development project. The script never creates or deletes a project.
Install aws, age, psql, and Supabase CLI 2.75.0 on the trusted operator
device. Then set:
export AWS_PROFILE=openadapt
export AWS_BACKUP_BUCKET='<CloudFormation BackupBucketName>'
export AWS_RESTORE_ROLE_ARN='<CloudFormation BackupRestoreRoleArn>'
export BACKUP_STAMP='<YYYYMMDDTHHMMSSZ from S3>'
export PRODUCTION_PROJECT_REF='<exact production project ref>'
export SCRATCH_PROJECT_REF='<new scratch project ref>'
export CONFIRM_SCRATCH_PROJECT_REF="$SCRATCH_PROJECT_REF"
export SCRATCH_DB_URL='<new scratch PostgreSQL URL>'
export AGE_SECRET_KEY_FILE='<absolute path to the private age key>'
scripts/run_database_restore_drill.shThe script:
- validates that the scratch URL belongs to the repeated scratch project;
- assumes the least-privilege restore role in AWS account
992382684924; - checks S3 public-access blocking;
- downloads the exact timestamped ciphertext and manifest;
- verifies the S3 metadata digest and the artifact contract;
- decrypts into a private temporary directory;
- extracts only four exact regular files and rejects unsafe archive members;
- restores with
ON_ERROR_STOPin one transaction; - dumps the scratch schema and data again, normalizes only the matched random
PostgreSQL restriction guard outside
COPYdata, and compares their digests; - writes a new database-only evidence file without overwriting old evidence;
- uploads that metadata-only evidence below
drills/database-only/; and - removes all temporary plaintext.
RTO starts before AWS role assumption and download. It ends after the scratch database redump and validation. RPO is measured from the backup recovery point to the same start time.
Patched PostgreSQL clients create a new \restrict key for each plain-text
dump. Supabase CLI comments that key, so two correct dumps have different raw
digests. The verifier keeps the raw source digests in the evidence and uses a
second comparison digest that replaces only one matched restrict and
unrestrict pair outside COPY blocks. A guard-shaped database value remains
data and a changed value still fails the drill.
The script does not delete the scratch project. Review the evidence first. Then decommission the project through its authorized owner process.
Database-only evidence has:
{
"database_restored": true,
"storage_restored": false
}It cannot unlock destructive hosted retention and is not the canonical Cloud
restore receipt. After the database-only drill, run the database and private
Storage procedure in openadapt-cloud/docs/RUNBOOK_DATA_SAFETY.md:
drill-storage-exportdrill-prepare- the exact roles, schema, and data dumps
drill-storage-check-source- scratch database restore
drill-storage-restoredrill-verifywithout--record- review the result
- repeat
drill-verifywith--record
Only that result proves the complete declared database and Storage boundary.
Prices can change. Verify the provider price before purchase.
| Option | Approximate monthly cost | Recovery point | Boundary |
|---|---|---|---|
| This daily logical backup | S3 bytes and requests only; approximately USD 0.023/GB-month | Up to 24 hours when consecutive jobs pass | Database only |
| Supabase Pro | USD 25 base plan | Daily provider backup with seven-day history | Database only |
| Supabase PITR | About USD 100 for seven days, plus the required paid plan and minimum compute | Minutes, based on retained WAL | Database only |
References:
- https://supabase.com/pricing
- https://supabase.com/docs/guides/platform/backups
- https://supabase.com/docs/guides/platform/point-in-time-recovery
- https://aws.amazon.com/s3/pricing/
Do not enable PITR without a recorded cost decision. A logical backup and a successful complete scratch drill are still required because provider database recovery does not cover private Storage objects.
For routine rotation:
- Generate a new private key on a trusted device.
- store it on the device and in the second vault or offline location;
- replace the committed public recipient;
- merge the change through code owner review;
- confirm the next backup uses only the new recipient; and
- keep the old private key until every old S3 object expires after 90 days.
If a private key is compromised, remove its public recipient immediately. Rotate the production database credential because retained backups contain the complete logical database. Treat all backups encrypted to the compromised key as exposed. Do not keep encrypting new backups to both old and new recipients.
A failed backup job stays red and opens or updates one durable GitHub issue.
The hourly Production DB backup freshness workflow uses a separate read-only
AWS role. It opens or updates one durable issue when the newest complete pair
is absent, stale, or inconsistent. A successful run closes its matching issue.
The two checks use different workflows and different AWS roles. They still use the same GitHub scheduler. Configure one external monitor to alert when either workflow stops running. The external monitor can inspect the workflow age or assume a separate read-only role and inspect the newest S3 recovery point. Do not give the external monitor the backup writer role.
GitHub can disable schedules after 60 days without repository activity. The daily documentation sync currently keeps this repository active. Verify the backup workflow enabled state during each quarterly drill. A schedule status is not backup evidence; check the newest S3 recovery point and drill receipt.