Backup restore test evidence for audits: what to record and prove
For developers who already run backups and need audit-ready proof that restores actually work. This shows exactly what to capture during a restore test, with commands, sample outputs, and an evidence checklist you can attach to a ticket or control record.
TL;DR — Auditors do not care that backups are "enabled"; they care that you can restore a specific backup, within a stated objective, and prove the restored data is usable. Record the backup identifier, source and target systems, timestamps, commands run, hashes/counts checked, errors seen, and the final pass/fail against your RPO/RTO; the most common miss is failing to keep immutable evidence like command output and row-count/hash comparisons. Reading time: ~5 min
Goal
When you are done, you will have a repeatable restore-test record that shows a named backup was restored to an isolated target, validated with concrete checks, timed against RPO/RTO, and stored as audit evidence with enough detail for another engineer to reproduce it.
Prerequisites
- Access to the system that created the backup and a separate restore target with enough disk space and network access
- Permission to read backup metadata and perform a restore in a non-production environment
- Shell access with common tools installed:
bashor compatible shellsha256sumorshasum -a 256jq >= 1.6— check withjq --versionpsql >= 15for PostgreSQL examples — check withpsql --versionmysql >= 8for MySQL examples if applicable — check withmysql --versiontarandgzipfor file/archive backups
- The exact backup identifier to test: object key, snapshot ID, dump filename, or backup job run ID
- Your control targets written down before you start:
- RPO, for example
24h - RTO, for example
2h
- RPO, for example
- A ticket, change record, or evidence folder where you will save logs, screenshots if required, and checksums
Steps
Step 1: Create an evidence folder and start a timestamped log
Run all restore-test commands through tee so you keep immutable output.
export TEST_ID="restore-test-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "evidence/$TEST_ID"
script -q -c 'bash' "evidence/$TEST_ID/session.log"
You should see a new shell prompt and, after exit, a file at evidence/<test-id>/session.log.
Step 2: Record the test scope and success criteria in a machine-readable file
Create a small JSON file before touching the backup. Replace the example values literally with your own.
cat > "evidence/$TEST_ID/plan.json" <<'JSON'
{
"system": "billing-db",
"environment": "production-backup-restored-to-staging",
"backup_id": "s3://company-backups/postgres/billing/2026-10-29T01:00:00Z.dump.gz",
"backup_time_utc": "2026-10-29T01:00:00Z",
"restore_target": "staging-db-restore-01",
"requested_by": "internal-control-cc7",
"operator": "jane.doe",
"rpo_target": "24h",
"rto_target": "2h",
"validation_checks": [
"database opens",
"critical tables row counts match expected tolerance",
"application smoke query succeeds"
]
}
JSON
jq . "evidence/$TEST_ID/plan.json"
You should see formatted JSON with your backup ID, target, RPO, and RTO.
Step 3: Capture source backup metadata before restore
Record size, checksum, and last-modified time from the backup source. For a local or mounted file:
BACKUP_FILE="/backups/billing/2026-10-29T01:00:00Z.dump.gz"
stat "$BACKUP_FILE" | tee "evidence/$TEST_ID/backup-stat.txt"
sha256sum "$BACKUP_FILE" | tee "evidence/$TEST_ID/backup-sha256.txt"
For an object store, use your provider CLI and save the output. Example with an S3-compatible CLI:
aws s3api head-object --bucket company-backups --key postgres/billing/2026-10-29T01:00:00Z.dump.gz | tee "evidence/$TEST_ID/backup-head-object.json"
You should see file size, modification time, and a checksum or ETag-like value recorded to disk.
Step 4: Restore to an isolated target and time it
⚠️ Do not restore onto production. Use a disposable database name, host, or VM. A restore can overwrite data, invalidate replicas, or trigger application jobs if you point it at the wrong target.
For PostgreSQL custom-format dump:
export PGHOST="staging-db-restore-01"
export PGPORT="5432"
export PGUSER="restore_user"
export PGPASSWORD='REDACTED'
export DBNAME="billing_restore_${TEST_ID//-/_}"
/usr/bin/time -p bash -c '
createdb "$DBNAME" && \
gunzip -c /backups/billing/2026-10-29T01:00:00Z.dump.gz | pg_restore --no-owner --no-privileges --clean --if-exists --dbname="$DBNAME" -v
' 2>&1 | tee "evidence/$TEST_ID/restore-output.txt"
Typical successful output shape:
pg_restore: connecting to database for restore
pg_restore: dropping TABLE public.invoices
pg_restore: creating TABLE public.invoices
pg_restore: processing data for table "public.invoices"
pg_restore: creating INDEX "public.invoices_pkey"
real 412.37
user 12.44
sys 3.21
You should see real <seconds> at the end and no final pg_restore: error: lines.
Step 5: Validate the restored data with objective checks
Do not stop at "restore command exited 0". Record at least one structural check and one business-data check.
For PostgreSQL:
psql -d "$DBNAME" -v ON_ERROR_STOP=1 -c "SELECT current_database(), now();" | tee "evidence/$TEST_ID/db-open.txt"
psql -d "$DBNAME" -v ON_ERROR_STOP=1 -c "SELECT 'customers' AS table, count(*) FROM customers UNION ALL SELECT 'invoices', count(*) FROM invoices UNION ALL SELECT 'payments', count(*) FROM payments;" | tee "evidence/$TEST_ID/row-counts.txt"
psql -d "$DBNAME" -v ON_ERROR_STOP=1 -c "SELECT max(created_at) AS latest_record_utc FROM invoices;" | tee "evidence/$TEST_ID/latest-record.txt"
If you have a canonical query your app depends on, run it too:
psql -d "$DBNAME" -v ON_ERROR_STOP=1 -c "SELECT customer_id, sum(amount) AS balance FROM invoices WHERE status = 'open' GROUP BY customer_id ORDER BY balance DESC LIMIT 5;" | tee "evidence/$TEST_ID/smoke-query.txt"
You should see row counts, a latest timestamp, and query results without ERROR: lines.
Step 6: Calculate pass/fail against RPO and RTO
Record the actual elapsed restore time and the age of the restored data.
jq -r '.backup_time_utc, .rpo_target, .rto_target' "evidence/$TEST_ID/plan.json"
python3 - <<'PY' | tee "evidence/$TEST_ID/result.txt"
from datetime import datetime, timezone
backup_time = datetime.fromisoformat('2026-10-29T01:00:00+00:00')
restore_end = datetime.now(timezone.utc)
rpo_hours = (restore_end - backup_time).total_seconds() / 3600
print(f"backup_age_hours={rpo_hours:.2f}")
print("restore_status=PASS_IF_restore-output_has_no_errors_and_real_time_within_target")
PY
You should see a numeric backup_age_hours= value you can compare to your RPO target.
Step 7: Write the final evidence summary auditors actually ask for
Create a single summary file with the facts. Replace values with your actual result.
cat > "evidence/$TEST_ID/summary.md" <<EOF
System: billing-db
Backup ID: s3://company-backups/postgres/billing/2026-10-29T01:00:00Z.dump.gz
Restore target: staging-db-restore-01 / database $DBNAME
Operator: jane.doe
Started UTC: $(date -u +%Y-%m-%dT%H:%M:%SZ)
Evidence files:
- session.log
- plan.json
- backup-stat.txt or backup-head-object.json
- backup-sha256.txt
- restore-output.txt
- db-open.txt
- row-counts.txt
- latest-record.txt
- smoke-query.txt
Result:
- Restore command exit status: 0
- Data validation: PASS
- RPO target met: YES
- RTO target met: YES
Exceptions: none
EOF
sed -n '1,200p' "evidence/$TEST_ID/summary.md"
You should see a concise summary listing the backup tested, where it was restored, what was checked, and the pass/fail result.
Verify it works
Use these checks to prove the control end-to-end:
test -s "evidence/$TEST_ID/session.log" && echo "session log present"
grep -E "^real [0-9]" "evidence/$TEST_ID/restore-output.txt"
grep -E "ERROR:|pg_restore: error:|FATAL:" "evidence/$TEST_ID/restore-output.txt" && echo "unexpected errors found" || echo "no restore errors found"
grep -E "customers|invoices|payments" "evidence/$TEST_ID/row-counts.txt"
Expected result:
session log present
real 412.37
no restore errors found
customers | 15234
invoices | 883421
payments | 870102
Your end state is correct when you have: a named backup artifact, a restore log with timing, validation outputs proving the restored data is usable, and a summary that explicitly states whether RPO and RTO were met.
Common pitfalls
Restoring the latest backup instead of the backup you documented
Mistake: You test "whatever is newest" but your ticket names a different backup ID.
Symptom: Audit evidence shows row counts and timestamps, but there is no provable link to the exact backup artifact.
Fix: Put the exact object key, snapshot ID, or filename in plan.json before the restore and save head-object/stat output for that same artifact.
Keeping only screenshots, not command output
Mistake: You attach a screenshot of a green dashboard or a terminal window.
Symptom: The auditor asks for timestamps, exit codes, or the exact error text and you cannot provide it.
Fix: Run the session under script and pipe critical commands through tee into files under evidence/$TEST_ID/.
Declaring success because the restore command exited 0
Mistake: You stop after pg_restore or import completes.
Symptom: The restored database exists, but required tables are empty, the wrong schema was loaded, or app queries fail.
Fix: Record at least one open/connect check, one row-count check on critical tables, and one application-relevant query.
Testing on a non-isolated target
Mistake: You restore into a shared staging database that already has data or background jobs.
Symptom: Row counts do not match expectations, validation is noisy, or jobs mutate the restored data during the test.
Fix: Restore into a uniquely named database like billing_restore_<timestamp> on an isolated host or disposable environment.
Ignoring warnings that indicate incomplete restores
Mistake: You treat warnings as harmless without reading them.
Symptom: Output contains lines like the following, and later checks fail:
pg_restore: warning: errors ignored on restore: 12
Fix: Search restore-output.txt for warning, error, FATAL, and ignored; if present, mark the test failed until you explain and remediate each one.
Not recording the time basis for RPO/RTO
Mistake: You say "met RTO" without showing start/end times or backup timestamp.
Symptom: The evidence cannot prove whether the restore was fast enough or recent enough.
Fix: Save the backup timestamp from metadata, keep the timed restore output with real <seconds>, and write the pass/fail decision in summary.md.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI