Backup & Restore Runbook¶
Scope: off-site backup + restore for prod (147.93.168.216) and the NUC gateway. As-built 2026-06-28. Scripts are synced in this repo under scripts/.
What's backed up¶
| What | Tool (on host) | Schedule | Destination | Retention |
|---|---|---|---|---|
pbx_api_db (MariaDB: orgs/users/DIDs/routing + CDR) | astradial-backup.sh (mysqldump --single-transaction) | hourly + daily | gcs:astradial-prod-backups/db/<tier>/ | GFS: hourly 30d → daily 90d → weekly 1y → monthly 5y |
workflow_db (PostgreSQL) | astradial-backup.sh (pg_dump) | hourly + daily | …/pgdb/<tier>/ | GFS |
| Redis snapshot | astradial-backup.sh (redis-cli --rdb) | hourly + daily | …/redis/<tier>/ | GFS |
Host configs (/etc/asterisk, /etc/wireguard, .env, nginx, image-repo pass) | astradial-backup.sh (tar) | daily | …/configs/<tier>/ | GFS |
| Prod full disk image | astradial-image-backup.sh (restic) | daily 04:00 IST | gcs:astradial-prod-image/restic | keep last 3 days |
| NUC config + disk image | nuc-backup.sh (tar + restic) | config daily 03:30, image weekly Sun 03:45 | gcs:astradial-nuc-backups/ | config: lifecycle; image: restic |
| Recordings | move-recordings.sh (rclone, --no-traverse) | every 5 min | gs://misssellerai.firebasestorage.app/astra_pbx/recordings/ | tier→Archive; purge per org-retention (future, ~2029) |
Crons live in root's crontab on each host. The hourly DB dump uses --single-transaction (MVCC snapshot, no locks → safe on the live DB). All image/restic uploads are bandwidth-capped + niced so they never compete with live call RTP.
Ransomware posture (immutability)¶
astradial-prod-backups(the critical DB/CDR/config backups): the prod keyastradial-backup-writeris Object Creator + Viewer only — it cannot delete or purge (verified). Object Versioning ON. Expiry is done by GCS lifecycle (Google-side), not the key. → a rooted prod cannot erase these backups.- Image buckets (
astradial-prod-image,astradial-nuc-backups): restic needs delete (locks/prune), so their keys have Object Admin scoped to that one bucket only. They hold rebuildable OS images (no customer data) → limited blast radius; the DB backups stay immutable. - Recordings (
misssellerai.firebasestorage.app): single Google-replicated copy; the Firebase key still has delete → not ransomware-hardened (follow-up: tighten / add versioning). - A 2nd off-account copy was deferred (2026-06-28); the controls above are the protection.
Monitoring¶
- Landing alert —
astradial-backup-check.shhourly at :15 → ntfy if newestdb/hourlyobject is stale (>90 min) or <1 MB. Reads status marker/var/lib/astradial-backup/last.env. (This caught a 6-day silent PostgreSQL backup failure on day one — a cronPATHbug, now fixed.) - Restore drill —
astradial-restore-drill.shmonthly (1st, 03:30) → restores the latest dump into scratchpbx_api_db_restore, asserts row counts vs live, ntfy on mismatch, drops scratch. Proves RTO without touching live.
Retention policy (recordings + CDR)¶
- Uniform: 1095 d recording (3 yr) / 1825 d CDR (5 yr) for ALL orgs (set 2026-06-28; new-org column default also 1095/1825). Chosen over per-industry presets for simplicity + fail-safe (never under-retain a hospital). SQL:
scripts/fix-org-retention.sql. - The "industry presets" in compliance.md were never implemented in code — the API does
INSERT IGNORE INTO org_compliance (org_id)and relies on the column default. - Purge (enforcement) is not built and not needed until ~April 2029 — with 3-yr retention the oldest recording (Apr 2026) isn't purge-eligible until then (CDR ~2031). Until then, lifecycle tiering → Archive is the cost control. Build the per-org purge (honoring
org_compliance.retention_*, right-to-erasure,audit_log) before 2029.
Cost / budget¶
Hard cap $2/mo GCS. Backups (DB+PG+Redis+configs+images) ≈ $0.85/mo; recordings auto-tier to Archive (~$0.0012/GB). Total ≈ $1.5/mo at current volume. At many-hospital scale, 3-yr no-purge recordings could approach $2/mo in ~2–3 yr → revisit retention/cost then (the alert warns first).
Budget alert (Zazmic India billing acct 018B3E-366D99-31DE78 → Budgets & alerts → "Astradial"): ₹200/mo, triggers 50/90/100% actual, emails billing admins. Scope = projects Astradial(misssellerai) + AstraDial(astradial), Services = Cloud Storage only — so GCS spend on either Astradial project pages, without false alerts from Gemini API spend on misssellerai. (It was mis-scoped to project monsoon and silently missed the Jul-2026 blowout — corrected 2026-07-14. If you add a new project that stores backups/recordings, add it to this budget's scope.)
⚠️ RCA — the ₹4,482 Jul-2026 blowout (list-egress, NOT storage). After the Jul-4 box cutover,
move-recordings.shran*/5and eachrclone copy/movetraversed the flatastra_pbx/recordings/prefix (~100k objects) in a us-central1 bucket → ~90 MB of list-JSON per sweep billed from Mumbai as inter-continent Download APAC egress (₹4,141 = 91% of the bill) + Class A ListObjects (₹291, +513%). Actual stored bytes were only ~₹99. Fix:--no-traverseon all 3 rclone calls → per-source-file HEAD instead of listing the huge destination (few new WAVs per run;movedeletes sources on success → identical upload, no data-loss). Cut ~95% of the cost. Watch for this whenever a flat, large-object-count prefix is synced repeatedly. Long-term: date-partition recordings (recordings/YYYY/MM/) and/or move the bucket toasia-south1.
Verify backups are healthy¶
ssh root@147.93.168.216
rclone lsl gcs:astradial-prod-backups/db/hourly/ | sort -k2 | tail -3 # fresh (<70 min) + ~4 MB
cat /var/lib/astradial-backup/last.env # LAST_STATUS=ok
gsutil versioning get gs://astradial-prod-backups # Enabled
restic -r rclone:gcs:astradial-prod-image/restic snapshots # prod image (needs RESTIC_PASSWORD_FILE)
Restore pbx_api_db (DR)¶
⚠️ Restoring over live is destructive — restore to a scratch DB first, verify, then cut over. Confirm per CLAUDE.md rule 1.
rclone lsl gcs:astradial-prod-backups/db/hourly/ | sort -k2 | tail # pick newest
rclone copy gcs:astradial-prod-backups/db/hourly/<file> /tmp/
mysql -e "CREATE DATABASE pbx_api_db_restore"
gunzip -c /tmp/<file> | mysql pbx_api_db_restore
mysql pbx_api_db_restore -e "SELECT COUNT(*) FROM organizations; SELECT COUNT(*) FROM did_numbers"
# only after approval, real restore: mysql pbx_api_db < <(gunzip -c <file>) # back up live first
…/pgdb/hourly/ → psql (as the postgres user). Restore a whole box (restic image)¶
# On a fresh VM: install restic+rclone, restore the rclone key + repo password.
# The image-repo password is inside the configs backup:
# astradial-prod-backups/configs/<tier>/configs-*.tar.gz → /etc/astradial-prod-image-restic.pass
restic -r rclone:gcs:astradial-prod-image/restic restore latest --target /
# Then load the latest DB dump on top, and re-point WireGuard + DNS.
# NUC: same with rclone:nucgcs:astradial-nuc-backups/restic (password in the NUC config tar).
Restic repo passwords (where they live + can they be stolen?)¶
The restic image repos are encrypted — lose the password and the image is unrecoverable. Each password is stored in two places, plus a recommended third: - On the host (chmod 600, root-only): /etc/astradial-prod-image-restic.pass (prod), /etc/astradial-nuc-restic.pass (NUC). - In GCS, inside that host's config tar — prod → astradial-prod-backups/configs/… (the immutable bucket); NUC → astradial-nuc-backups/config/…. The config tar is a plain rclone copy (readable with the GCS key, not restic-encrypted), so DR isn't circular: fetch the tar → extract the password → restore the image. - Recommended third copy: Vaultwarden. cat each file and paste it in. With host + GCS + Vaultwarden, losing any two still leaves the images recoverable.
Can an attacker read it on the box? Only with root. 600 blocks every non-root user, but root reads everything — as it does for every secret the box uses (rclone GCS key, WireGuard keys, .env, Firebase key). An unattended cron backup inherently needs the password readable by root; you can't hide it from root on the machine that uses it. Therefore: - The real defense is preventing root compromise — SSH key-only, fail2ban, ufw + GeoIP, auto-updates (all in place). Vaultwarden guards against losing the password, not theft. - Critical data survives a full root compromise regardless: the DB/CDR/config backups use a no-delete key + versioning in astradial-prod-backups, so a rooted box can't wipe them (and they're plain mysqldump, no restic password). The password only matters for the image backups (rebuild convenience) — a root attacker could decrypt/delete those, but you'd still rebuild from the immutable DB backups. - restic encryption still protects the image at rest in GCS against a GCS-only leak (bucket read without the host ≠ readable contents).
Scripts (synced in this repo)¶
astradial-backup.sh · astradial-backup-check.sh · astradial-restore-drill.sh · astradial-image-backup.sh · nuc-backup.sh · fix-org-retention.sql — under scripts/. The deployed copies live in /usr/local/sbin/ on each host (VPS is source of truth; keep these in sync).