Disaster Recovery Runbook
Classification: Critical Operations
Owner: Platform Maintainers
Review cycle: Quarterly
Severity Levels
| Level | Description | RTO | RPO |
|---|---|---|---|
| P0 | Total service loss | 1 hour | 24 hours |
| P1 | Partial degradation | 4 hours | 24 hours |
| P2 | Performance impact | 24 hours | 48 hours |
DR-01: Full Server Loss
Trigger: Server unreachable, disk failure, or provider incident.
What you need: a VayuKeep generation (or a vayupress backup archive) from
somewhere other than the lost server, its passphrase, and a copy of
/etc/vayupress/env.
# 1. Provision a new Ubuntu 24.04 LTS server (8 GB RAM, 4 vCPU, 50 GB)
# 2. Install VayuPress on it, for the same domain
curl -sSL https://raw.githubusercontent.com/johalputt/vayupress/main/scripts/deploy-vayupress.sh | bash
# 3. Copy the archive across, verify it, and restore over the fresh install
scp vk-YYYYMMDD-HHMMSS.vpbk root@new-server:/root/
vayupress restore -in /root/vk-YYYYMMDD-HHMMSS.vpbk -verify
sudo systemctl stop vayupress
sudo vayupress restore -in /root/vk-YYYYMMDD-HHMMSS.vpbk -dest /var/lib/vayupress
sudo chown -R www-data:www-data /var/lib/vayupress
# 4. Put back /etc/vayupress/env (secrets, VayuKeep target, Cloudflare token)
sudo systemctl start vayupress
# 5. Point the A/AAAA records at the new server; certbot issues certificates
# again on the next installer run or renewal
# 6. Verify
curl -s http://localhost:8080/health
Expected recovery time: 45–60 minutes, most of it DNS.
DR-02: Database Corruption
Trigger: SQLITE_CORRUPT or disk I/O errors in the log, the service
failing to start on a database error, or the install in read-only mode.
Follow wal-corruption-recovery.md: stop the service, preserve the damaged files, try a WAL checkpoint, and otherwise restore the newest generation that verifies (DR-07).
DR-03: Migration Checksum Drift (Tamper Detected)
Trigger: The service refuses to start with migration drift in the log.
At every start the server compares each applied migration's recorded SHA-256
with the migration shipped in the binary (internal/db), and stops if one
differs.
# 1. See which migrations drifted
sudo journalctl -u vayupress -n 50 | grep -i drift
# 2. Read what the database recorded for them
sqlite3 /var/lib/vayupress/vayupress.db \
"SELECT version, checksum, applied_at FROM schema_migrations ORDER BY version;"
A shipped migration is never edited after release, so drift means the database's record was changed, or the binary is not the one released. Treat it as a security incident:
- Check the binary:
go version -m /usr/local/bin/vayupressnames the module and version it was built from; compare it with the release's signature. - Change every administrator's password and revoke API keys and connector grants (VayuOS → Settings › Team, and VayuOS → API keys).
- Restore the newest generation from before the drift appeared (DR-07), and file the incident per incident-response.md.
DR-04: TLS Certificate Expiry
Trigger: Certbot renewal failure, https:// returns certificate error.
# Check certificate status
sudo certbot certificates
# Force renewal
sudo certbot renew --force-renewal --nginx
# Verify
echo | openssl s_client -connect yourdomain.com:443 2>/dev/null | \
openssl x509 -noout -dates
# If Let's Encrypt is unavailable — install self-signed temporarily
sudo openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/private/vp-temp.key \
-out /etc/ssl/certs/vp-temp.crt -days 30 -nodes \
-subj "/CN=yourdomain.com"
DR-05: Search Degraded
Trigger: /health/search returns "status": "degraded". Articles still readable; search returns empty.
Search is built in (VayuFind, ADR-0101) — there is no second service to restart. A degraded result means the index is missing or stale, so recovery is a rebuild, not a restart.
# Confirm the engine is answering, and check the log for index errors.
curl -s http://localhost:8080/health/search
sudo journalctl -u vayupress -n 50 | grep -i search
# Rebuild the index (safe, non-destructive — articles are never touched).
curl -X POST http://localhost:8080/admin/reindex \
-H "X-API-Key: $VAYU_API_KEY"
If a rebuild does not clear it, the index lives inside the VayuPress database, so DR-01 (database restore) is the escalation — not a separate search recovery.
DR-06: Backup Verification Failure
Trigger: VayuOS → System › Backups shows Restore FAILED, or
journalctl -u vayupress | grep vayukeep contains restore drill FAILED.
This is an outage of your recovery path, not a warning. The generations may be present, recent and the right size, and still not restore.
# What the engine itself reports
sudo journalctl -u vayupress -n 200 | grep vayukeep
# List generations on the target
ls -lh "$VAYUKEEP_TARGET"
# Verify one end to end without writing anything — passphrase, every
# authentication tag, the chain between frames, and the final marker
vayupress restore -in "$VAYUKEEP_TARGET/vk-YYYYMMDD-HHMMSS.vpbk" -verify
If -verify passes but the drill fails, the archive is intact and the database inside it
is not: restore it somewhere scratch and run PRAGMA integrity_check by hand.
If -verify fails, that generation is unusable — check the ones before it and treat the
target as suspect (full disk, failing mount, silent truncation on write).
DR-07: Point-in-Time Restore
Trigger: Bad data was written and you need the site as it was before a known moment — a bad import, a mistaken bulk edit, a compromised session.
VayuKeep keeps encrypted generations; restoring "as of" a moment selects the last generation taken at or before it. It never rolls forward past the moment you asked for, because that would hand back the data you are trying to escape.
# 1. See what you have (newest first)
ls -1 "$VAYUKEEP_TARGET" | sort -r
# 2. Verify the one you intend to use BEFORE trusting it
vayupress restore -in "$VAYUKEEP_TARGET/vk-20260727-104500.vpbk" -verify
# 3. Stop the service, then restore
sudo systemctl stop vayupress
sudo vayupress restore -in "$VAYUKEEP_TARGET/vk-20260727-104500.vpbk" -dest /var/lib/vayupress
sudo chown -R www-data:www-data /var/lib/vayupress
# 4. Start, and confirm
sudo systemctl start vayupress
curl -s http://localhost:8080/health
Your previous data directory is preserved, not deleted — the restore prints the path
it was moved to (/var/lib/vayupress.replaced-<timestamp>). Keep it until you have
confirmed the restored generation is the one you wanted, then remove it.
A restore that fails partway leaves the live directory untouched: entries are staged into a sibling directory and moved into place only after the archive reaches its authenticated final marker.
Post-Incident Requirements
After any P0/P1 incident:
- Within 1 hour: Incident declared in
#incidentsSlack channel - Within 24 hours: Preliminary report filed (timeline, impact, immediate fix)
- Within 7 days: Full post-mortem published in
docs/post-mortems/YYYY-MM-DD-<slug>.md - Within 30 days: Corrective actions implemented and verified
Post-mortem template: docs/rfc-template.md (adapt Section 4 for incident context).
Backup Schedule
Backups are taken by VayuKeep, inside the binary — there is no cron, no timer and no
sqlite3 CLI in the path. Point it at a target and it runs.
| What | Frequency | Retention | Location |
|---|---|---|---|
| Everything (database, media, mailboxes, settings, PGP public material) | Adaptive: from every 5 min while writing, backing off to 6 h when idle | 24 generations or 30 days, whichever keeps more | $VAYUKEEP_TARGET |
| Restore drill | Every 12 h, and on demand | last result only | temporary, discarded |
VAYUKEEP_TARGET=/mnt/backup/vayupress # anywhere outside the data directory
VAYU_BACKUP_PASSPHRASE=… # the same passphrase `vayupress backup` uses
Setting a target IS the intent to replicate, so there is no second on/off switch to
forget. VAYUKEEP_OFF=true disables it explicitly.
Every generation's database is copied through one pinned read transaction, so it is one moment's data without stopping the service. The copy, the seal and the test restore all go in steps, as fast as the server can spare, and slow down while visitors need the disk; System › Backups shows the copy running and why it is going at that pace. Generations are sealed with AES-256-GCM under an Argon2id-wrapped key, with each frame chained to the last. Nothing is written unencrypted, and there is no option to.
The recovery point is only as good as the last drill. A generation nothing has read back is a file, not a backup. Check last verified restore on VayuOS → System › Backups, not enabled.
Historical note: before v3.15.80 this table described a nightly 02:00 UTC job that did not
exist. The only database backup the deploy script installed ran inside if $UPGRADE, so
the real recovery point was "whenever you last upgraded". If you sized your risk from the
old table, re-check it.
See also: docs/OPERATIONS.md for day-to-day runbooks (RB-01 through RB-09).