Master overview (PHASE4_1_PREPRODUCTION_HARDENING.md) covering all 27 substantive items, plus REMOTE_AND_SELF_PACED_ARCHITECTURE.md (reviews what already works for remote/self-paced delivery - student-driven provisioning, cohort-less students, idle-reaper-destroy-then-continue - and the one real gap: student authentication, explicitly Phase 5 scope, not built here). Updated PRODUCTION_NETWORK_SECURITY.md (the Gateway-to-Lab gap is now RESOLVED, with the full design writeup and a new control-plane-vs-data-plane section), PRODUCTION_DEPLOYMENT.md (four-cert TLS bundle), PRODUCTION_VALIDATION_CHECKLIST.md (Router mTLS check, admission control checks), PHASE4_PRODUCTION_INFRASTRUCTURE.md and ENGINEERING_SOP.md and LAB_ARCHITECTURE.md (stale 'known gap' pointers updated to point at the resolution), BACKUP_DR.md (destructive drill), README.md and platform/README.md (Phase 4.1 pointers, lab-router component, updated known-limitations). Also fixes a real pre-existing bug found while auditing what gets committed: infra/.gitignore's patterns were written repo-root-relative (e.g. "infra/terraform/...") but the file lives in infra/, so git resolved them relative to THAT directory and none of them ever matched anything - a real .terraform/ provider cache showed up as untracked when it should have been ignored. Fixed by dropping the redundant leading "infra/" from every pattern. Status: PHASE 4.1 COMPLETE / PRE-PRODUCTION HARDENING COMPLETE / READY FOR HARDWARE DEPLOYMENT.
5.3 KiB
Backup & Disaster Recovery
What's backed up (Tier 1) vs. not
| Backed up | Why | |
|---|---|---|
| Postgres data | Yes, daily | The only irreplaceable data — student records, cohort/session state, scenario attempts, assessments, achievements, audit events |
{{ rootnode_data_root }} config/secrets (Vault files, env templates) |
Yes, via the infra repo + Ansible Vault itself | Small, static, versioned |
Infra code (infra/, Ansible, Terraform state) |
Yes — it's in git; Terraform state should be backed up alongside Postgres if not using a remote backend | Needed to rebuild either VM from scratch |
| Disposable student Labs | No, intentionally | Ephemeral by design — a Lab is meant to be destroyed and recreated; backing it up would contradict the whole isolation/disposability model |
| Prometheus/Loki metrics & log history | No | Operational telemetry, not source-of-truth data; losing 30 days of dashboards on a rebuild is an inconvenience, not data loss |
Backup mechanism
infra/backup/backup-postgres.sh (deployed to /opt/rootnode/backup/ by the backup Ansible
role, run daily at 03:15 via cron):
pg_dump --format=plain --no-owner --no-privileges | gzip -9- Sanity check: reject if the compressed dump is under 1KB (almost certainly means Postgres was unreachable or empty, not a real backup).
gpg --symmetric --cipher-algo AES256— encrypt, then delete the plaintext dump immediately.- Optional off-host copy via
rsync -azover SSH — works for Hetzner Storage Box or any SSH target; that's the extension point for S3-compatible alternatives (swap the rsync line foraws s3 cp/rcloneif a different target is chosen later). - Retention pruning (
find ... -mtime +{{ backup_retention_days }} -delete, default 30 days). - Writes Prometheus textfile-collector metrics
(
rootnode_backup_last_run_success,..._last_success_timestamp_seconds,..._size_bytes) atomically (tmp file + rename) so a half-written metric file is never scraped.
Restore
infra/backup/restore-postgres.sh — requires an explicit target DB URL (never infers or
defaults to production, to prevent an accidental overwrite), shows the target's current tables,
requires typing RESTORE to confirm, then decrypts and pipes into psql.
infra/backup/verify-restore.sh — confirms all 8 expected tables exist plus the
schema_migrations row count, so a "successful" restore that silently missed a table is caught.
Genuinely tested, not just written: infra/backup/test-backup-restore.sh runs the full
cycle against two disposable local Postgres containers, with the actual production migrations
applied (3 Provisioner + Platform API, in the correct cross-service order), a
sample row inserted, backed up, restored into the second container, and verified byte-for-byte.
Confirmed passing, exit 0.
Phase 4.1's destructive drill goes one step further
(infra/backup/test-destructive-restore-drill.sh): realistic data (owner, admin instructor,
cohort, 3 students, progress, attempts, an assessment definition, achievements) seeded through
the REAL Platform API, backed up, the source Postgres container genuinely destroyed
(docker rm, not just left running alongside a second one), a fresh Postgres created from
nothing, restored, and the real Platform API booted again against the restored database and
used to log in as the restored admin instructor over HTTP — proving the whole stack recovers,
not just the SQL. Never touches the developer's real persistent database. Confirmed passing.
Disaster recovery scenarios
| Scenario | RPO | RTO (estimate) | What happens |
|---|---|---|---|
| Platform VM lost, disks intact | 0 | ~30 min | docs/RUNBOOK_VM_REBUILD.md — Ansible re-run against the same disks |
| Platform VM + its disks lost | Up to 24h (since last backup) | ~1-2h | Rebuild VM (Terraform), re-run Ansible, restore Postgres from the latest off-host backup (docs/RUNBOOK_POSTGRES_RESTORE.md) |
| Lab Host VM lost, any reason | 0 (intentional) | ~30 min | Rebuild VM, re-run Ansible — no data to restore, all Labs were disposable and get recreated on next student login |
| Single disk failure in a mirror | 0 | 0 (no outage) | docs/RUNBOOK_DISK_FAILURE.md — pool stays online DEGRADED, replace and resilver |
| Whole storage pool lost (both mirror sides) | Up to 24h | ~1-2h | Same as "Platform VM + disks lost" above — this is precisely the scenario the off-host copy exists for; a local-only backup on the same failed pool is useless here |
| Encryption passphrase lost | N/A | Unrecoverable | The single most important secret to protect and back up separately (e.g., in the same secrets manager as other Vault secrets, not only in production.vault.yml) — losing it makes every existing backup file permanently unreadable |
RPO is bounded by backup cadence (daily) — a mid-day incident can lose up to a day of student activity. If that's ever unacceptable, the fix is a shorter cron interval or WAL archiving for point-in-time recovery, not decided or built here since daily has not yet been proven insufficient against real usage.
What backup does NOT cover
Application code and container images — those live in Gitea and the image registry, both
already durable and versioned independently of this backup mechanism. TLS certificates — those
are regenerable (docs/RUNBOOK_DOCKER_TLS_ROTATION.md, docs/RUNBOOK_TLS_RENEWAL.md), not
backed up.