Database Backup Drill
Online SQLite backups with integrity verification, restore drills, retention rotation, and corruption alerting
"We have backups" means nothing until you have restored one. This workflow takes an online backup, runs an integrity check, actually restores it into a scratch database and compares row counts, then rotates old copies. A second task re-verifies the newest backup on a schedule and alerts if it has gone bad.
Executed for real against OrchStep v0.7.1 and a real SQLite database - including the corruption failure path.
What it demonstrates
| Capability | Where |
|---|---|
| Real tool delegation | sqlite3 .backup / PRAGMA integrity_check |
| Step-scoped cleanup | finally: drops the scratch restore DB |
| Cross-step verification | source vs restored row counts via outputs |
| Retention policy | rotate keeps the newest keep files |
| Scheduled re-verification + alerting | verify_latest with a catch wrapper |
The workflow
name: db-backup-drill
desc: "Backup a SQLite database, verify integrity, rotate old backups, and prove restorability"
defaults:
db_file: "./app.db"
backup_dir: "./backups"
keep: "3"
tasks:
seed_demo_db:
desc: "Create a demo database to protect (demo helper)"
steps:
- name: seed
func: shell
do: |
rm -f {{ vars.db_file }}
sqlite3 {{ vars.db_file }} "CREATE TABLE users(id INTEGER PRIMARY KEY, email TEXT); \
INSERT INTO users(email) VALUES ('a@x.io'),('b@x.io'),('c@x.io'),('d@x.io');"
echo "seeded $(sqlite3 {{ vars.db_file }} 'SELECT COUNT(*) FROM users;') users"
backup:
desc: "Take a consistent online backup and verify it end to end"
steps:
- name: snapshot
desc: "Online backup via sqlite's backup API (safe under writes)"
func: shell
do: |
mkdir -p {{ vars.backup_dir }}
TS=$(date +%Y%m%d-%H%M%S)
sqlite3 {{ vars.db_file }} ".backup {{ vars.backup_dir }}/app-$TS.db"
echo "file={{ vars.backup_dir }}/app-$TS.db"
outputs:
file: '{{ result.output | regexFind "file=(.+)" }}'
- name: integrity
desc: "A backup you have not verified is not a backup"
func: shell
do: sqlite3 {{ steps.snapshot.file }} "PRAGMA integrity_check;"
outputs:
check: "{{ result.output }}"
- name: source_rows
func: shell
do: sqlite3 {{ vars.db_file }} "SELECT COUNT(*) FROM users;"
outputs:
count: "{{ result.output }}"
- name: restore_drill
desc: "Restore into a scratch file and compare row counts"
func: shell
do: |
cp {{ steps.snapshot.file }} ./scratch-restore.db
sqlite3 ./scratch-restore.db "SELECT COUNT(*) FROM users;"
outputs:
count: "{{ result.output }}"
finally:
- name: drop_scratch
func: shell
do: rm -f ./scratch-restore.db
- name: verify
func: assert
args:
conditions:
- condition: '{{ eq steps.integrity.check "ok" }}'
desc: "backup passes PRAGMA integrity_check"
- condition: '{{ eq steps.source_rows.count steps.restore_drill.count }}'
desc: "restored row count matches the source"
- name: rotate
desc: "Keep only the newest N backups"
func: shell
do: |
cd {{ vars.backup_dir }}
DOOMED=$(ls -1t app-*.db | tail -n +$(( {{ vars.keep }} + 1 )))
if [ -n "$DOOMED" ]; then echo "$DOOMED" | xargs rm -v; else echo "nothing to rotate"; fi
echo "kept=$(ls -1 app-*.db | wc -l | tr -d ' ')"
outputs:
kept: '{{ result.output | regexFind "kept=([0-9]+)" }}'
- name: summary
func: shell
do: 'echo "BACKUP_OK file={{ steps.snapshot.file }} rows={{ steps.source_rows.count }} kept={{ steps.rotate.kept }}"'
verify_latest:
desc: "Standalone check of the newest backup, with alerting if it is corrupt"
steps:
- name: check_latest
task: _check_latest_inner
catch:
- name: alert
func: shell
do: 'echo "ALERT: latest backup failed verification - investigate before relying on it"'
_check_latest_inner:
steps:
- name: find_latest
func: shell
do: ls -1t {{ vars.backup_dir }}/app-*.db | head -1
outputs:
file: "{{ result.output }}"
- name: integrity
func: shell
do: |
RES=$(sqlite3 {{ steps.find_latest.file }} "PRAGMA integrity_check;" 2>&1 || true)
echo "result=$RES"
test "$RES" = "ok"Run it
orchstep run seed_demo_db # demo database
orchstep run backup # run it a few times to see rotation
orchstep run verify_latest # re-check the newest backup any timeVerified results:
BACKUP_OK file=./backups/app-20260611-221856.db rows=4 kept=3
# after corrupting the newest backup file:
ALERT: latest backup failed verification - investigate before relying on itFour backups were taken; rotation kept exactly 3. The corrupted-backup run still exits 0 because the failure was caught and alerted - that is the behavior you want from a monitoring task.
Design notes
The restore drill is the point. integrity_check catches file
corruption, but only a real restore proves the backup is usable. The drill
restores to a scratch path and asserts the row count matches the source -
swap in your real sanity queries.
Internal task convention. _check_latest_inner starts with _ so it
is hidden from orchstep menu and list-tasks; the public verify_latest
wraps it with catch-based alerting (in v0.7.1 catch/finally execute at step
level, so wrapping a task call is the recovery pattern).
Take it to production
Point it at Postgres (pg_dump/pg_restore) or MySQL the same way, ship
the backup to object storage in snapshot, and run verify_latest from
cron or CI on a schedule. The drill structure does not change.