Applies from: issue #36 (persistence migration) onwards. Until then the service is in-memory and a restart re-seeds from
scripts/seed.ts— no backup is needed today.
Once intents move off in-memory storage, they represent real financial state: pending swaps, locked funds, and solver commitments. Loss of this data means users cannot verify historical fills, solvers cannot dispute slash events, and the protocol stats dashboard shows incorrect totals.
This runbook covers:
- Scheduled automated backups
- Manual on-demand backups
- Verifying backup integrity
- Full and point-in-time restore procedures
- Restore drill schedule
| Component | Value |
|---|---|
| Database | PostgreSQL 15 (primary + 1 replica) |
| Hosting | AWS RDS (or self-managed on EC2) |
| Backup storage | S3 bucket vortex-backups-<env> |
| Retention | 30 days daily, 12 months monthly |
| Region | Same region as the service to minimise egress |
| Encryption | AES-256 at rest (S3 SSE-S3 or SSE-KMS) |
Adjust the bucket name, region, and credentials in .env / ECS task
definition before using any command below.
# .env (never commit real values)
DB_HOST=localhost
DB_PORT=5432
DB_NAME=vortex
DB_USER=vortex_app
DB_PASSWORD=<secret>
BACKUP_S3_BUCKET=vortex-backups-prod
AWS_REGION=us-east-1Add the following cron job to the database host (or a dedicated ops container):
# /etc/cron.d/vortex-backup
# Daily at 02:00 UTC
0 2 * * * postgres /opt/vortex/scripts/backup-daily.sh >> /var/log/vortex-backup.log 2>&1scripts/backup-daily.sh:
#!/usr/bin/env bash
set -euo pipefail
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
DUMP_FILE="/tmp/vortex-${TIMESTAMP}.dump"
echo "[backup] Starting full backup at ${TIMESTAMP}"
# 1. Create a binary-format dump (faster restore than plain SQL)
PGPASSWORD="${DB_PASSWORD}" pg_dump \
--host="${DB_HOST}" \
--port="${DB_PORT}" \
--username="${DB_USER}" \
--format=custom \
--compress=9 \
--file="${DUMP_FILE}" \
"${DB_NAME}"
# 2. Upload to S3
aws s3 cp "${DUMP_FILE}" \
"s3://${BACKUP_S3_BUCKET}/daily/${TIMESTAMP}.dump" \
--sse AES256 \
--region "${AWS_REGION}"
# 3. Clean up local file
rm -f "${DUMP_FILE}"
echo "[backup] Done — s3://${BACKUP_S3_BUCKET}/daily/${TIMESTAMP}.dump"Apply this lifecycle rule to vortex-backups-<env>:
{
"Rules": [
{
"ID": "expire-daily-backups",
"Prefix": "daily/",
"Status": "Enabled",
"Expiration": { "Days": 30 }
},
{
"ID": "expire-monthly-backups",
"Prefix": "monthly/",
"Status": "Enabled",
"Expiration": { "Days": 365 }
}
]
}On the first day of each month the cron should also copy the daily backup to
monthly/ before the daily prefix's 30-day expiry deletes it.
Enable automated backups and set the retention window to 7 days in Terraform /
the AWS console. This gives point-in-time recovery (PITR) within the last
7 days at no extra scripting cost, and the backup-daily.sh script above
handles the longer 30-day / 12-month archive tier.
Run this whenever you need a backup outside the scheduled window (e.g. before a schema migration, before a major deploy):
# From any machine with psql/pg_dump and AWS CLI access
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
PGPASSWORD="${DB_PASSWORD}" pg_dump \
--host="${DB_HOST}" \
--port="${DB_PORT}" \
--username="${DB_USER}" \
--format=custom \
--compress=9 \
--file="/tmp/vortex-manual-${TIMESTAMP}.dump" \
"${DB_NAME}"
aws s3 cp "/tmp/vortex-manual-${TIMESTAMP}.dump" \
"s3://${BACKUP_S3_BUCKET}/manual/${TIMESTAMP}.dump" \
--sse AES256After each automated backup, verify the file is readable:
# List the custom-format table of contents (non-destructive)
PGPASSWORD="${DB_PASSWORD}" pg_restore \
--list \
"/tmp/vortex-${TIMESTAMP}.dump" | head -20For a deeper check, restore to a throwaway database:
# Create a scratch DB
psql -c "CREATE DATABASE vortex_verify;"
# Restore
PGPASSWORD="${DB_PASSWORD}" pg_restore \
--host="${DB_HOST}" \
--username="${DB_USER}" \
--dbname="vortex_verify" \
--no-privileges \
--no-owner \
"/tmp/vortex-${TIMESTAMP}.dump"
# Spot-check row counts
psql -d vortex_verify -c "SELECT COUNT(*) FROM intents;"
psql -d vortex_verify -c "SELECT COUNT(*) FROM intent_audit_log;"
# Tear down
psql -c "DROP DATABASE vortex_verify;"Add a weekly cron that runs the above and alerts to Slack / PagerDuty if the row count differs from the primary by more than 1 %.
Use this when the database is unrecoverable (disk failure, accidental DROP).
Expected RTO: ~15 minutes for a 1 GB database.
# Step 1: Download the latest (or chosen) backup
BACKUP_KEY="daily/20260101T020000Z.dump" # ← set to the desired backup
aws s3 cp \
"s3://${BACKUP_S3_BUCKET}/${BACKUP_KEY}" \
/tmp/restore.dump
# Step 2: Stop the application to prevent writes during restore
# (scale ECS service to 0, or set MAINTENANCE_MODE=true)
# Step 3: Drop and recreate the target database
psql -c "DROP DATABASE IF EXISTS ${DB_NAME};"
psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
# Step 4: Restore
PGPASSWORD="${DB_PASSWORD}" pg_restore \
--host="${DB_HOST}" \
--port="${DB_PORT}" \
--username="${DB_USER}" \
--dbname="${DB_NAME}" \
--no-privileges \
--no-owner \
--exit-on-error \
/tmp/restore.dump
# Step 5: Verify
psql -d "${DB_NAME}" -c "SELECT COUNT(*) FROM intents;"
# Step 6: Restart the applicationIf using AWS RDS with automated backups enabled:
- Open the RDS console → select the
vortex-prodinstance. - Choose Actions → Restore to point in time.
- Set the target time (UTC) to 1 minute before the incident.
- Launch as a new instance (
vortex-prod-restored). - Update
DB_HOSTin the ECS task definition / Parameter Store to point at the new instance. - Validate row counts and application health, then delete the original instance or rename it as a snapshot.
To restore only the intents table without affecting other tables:
PGPASSWORD="${DB_PASSWORD}" pg_restore \
--host="${DB_HOST}" \
--username="${DB_USER}" \
--dbname="${DB_NAME}" \
--table=intents \
--data-only \
/tmp/restore.dumpWarning: restoring
intentsdata-only without also restoringintent_audit_logwill leave audit entries orphaned if foreign-key constraints referenceintents.intent_id. Restore both tables together or temporarily disable FK constraints.
| Alert | Condition | Destination |
|---|---|---|
| Backup missing | No new object in daily/ after 03:00 UTC |
PagerDuty P2 |
| Backup size anomaly | File size drops >30 % vs 7-day average | Slack #ops-alerts |
| Restore drill failed | Weekly verify cron exits non-zero | PagerDuty P2 |
| RDS storage > 80 % | CloudWatch metric | PagerDuty P1 |
A backup that has never been tested is not a backup.
| Frequency | Activity | Owner |
|---|---|---|
| Weekly | Automated integrity check (section 6) | Cron job |
| Monthly | Manual restore to vortex-staging and smoke-test the API |
On-call engineer |
| Quarterly | Full disaster-recovery drill: take prod offline, restore from backup, measure RTO | Engineering lead |
Document each drill result in the #ops-drills Slack channel with:
- Date and time
- Backup file used
- Actual RTO achieved
- Any issues found and remediation taken
- #36 — Replace in-memory store with persistent database (prerequisite for this runbook)
- #59 — Standalone seed script for local dev
- #60 — Database indexes for
getByUser/getByStatequery patterns - #62 — Audit trail for cancelled and expired intents