DOC_INDEX
THEME
DOCS/Use Cases/Database Backup Drill

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

CapabilityWhere
Real tool delegationsqlite3 .backup / PRAGMA integrity_check
Step-scoped cleanupfinally: drops the scratch restore DB
Cross-step verificationsource vs restored row counts via outputs
Retention policyrotate keeps the newest keep files
Scheduled re-verification + alertingverify_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 time

Verified 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 it

Four 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.