A real restore drill, start to finish

Marketing pages show mock terminals. These are the real transcripts of a BackupDrill restore drill, run on 2026-07-25 against a 197 MB Supabase-shaped Postgres 17 database and 250 files in an S3-compatible store — plus the script that reproduces it, so you do not have to take our word for any of it. Two edits, both cosmetic: long s3:// paths are shortened with an ellipsis, and the machine-readable JSON each command also prints is left out. Every line the CLI wrote to the terminal is here, warnings included.

Read this first.The database here is a fixture we built and seeded, not a customer’s production project — we are not going to publish someone else’s data, and we are not going to dress up a demo as a case study. It is a local Postgres 17 shaped like a Supabase project, and the files live in a local S3-compatible store standing in for Supabase Storage; no hosted Supabase project took part in this run. That has one visible consequence, and you can see it in the transcript below: the backup could not read storage.buckets, so it recorded bucket attributes as absent rather than inventing them — against a real project they are captured, provided the database role you connect with can read that table. What is real is the engine, the checks, and every number below.

The backup

One command wrote a dump, a full copy of the Storage files, a checksummed manifest, and a self-contained recovery runbook into an S3-compatible bucket:

$ backupdrill backup --config backupdrill.config.json

→ Inspecting database…
✓ Postgres 17.10 — schema(s) public: 13 tables, ~1,495,499 rows
→ Dumping → s3://acme-backups/backupdrill/acme-prod/2026-07-25T14-07-06-441Z/dump.pgcustom
✓ Database dumped (21.6 MB, sha256 210958d28579…)
→ Syncing Storage files…
→   user-uploads: 250 files
! Bucket attributes not captured (relation "storage.buckets" does not exist); this manifest will record them as absent — restore will say so instead of guessing.
✓ Storage synced (250 files, 33.6 MB)
✓ Recovery runbook written → …/RECOVERY.md
✓ Manifest written → …/manifest.json
✓ Backup complete — 13 tables + 250 Storage files.

253 objects landed in the bucket: the dump, the manifest, the runbook, and the 250 files. Nothing proprietary — dump.pgcustom is plain pg_dump output that stock pg_restore reads with or without us.

The drill

The drill downloads that snapshot into a throwaway sandbox, provisions a temporary Postgres, restores into it, verifies the result against the manifest, and destroys the sandbox:

$ backupdrill drill --config backupdrill.config.json \
    --verify-all-files --check-cmd "node check-invariants.mjs"

→ Drilling snapshot 2026-07-25T14-07-06-441Z
→ Downloading dump…
→ Verifying Storage files…
→ Starting ephemeral postgres:17-alpine…
→ Restoring into ephemeral Postgres…
→ Running app checks…
row counts: {"orders":"200000","order_items":"600000","customers":"20000","attachments":"250","events":"300000"}
orphan order_items: 0 | orphan orders: 0
revenue orders vs matview: 25099150000 vs 25099150000
all business invariants hold
→ Tearing down ephemeral Postgres…

  ✓ archive integrity — sha256 matches (210958d28579…)
  ✓ storage file integrity — all 250 files match their manifest checksums
  ✓ sandbox extensions — installed pg_trgm, pgcrypto
  ✓ pg_restore — completed in 2.3s
  ✓ post-data objects — user objects restored; 5 Supabase-managed object(s) skipped
  ✓ table count — restored 13, manifest 13
  ✓ no missing tables — all manifest tables present
  ✓ populated tables came back — no populated table restored empty
  ✓ app checks — command exited 0 in 0.1s

✓ Drill PASSED — snapshot 2026-07-25T14-07-06-441Z, 13 tables / 1,495,250 rows restored in 2.3s

What each check actually proves

archive integritysha256 matches (210958d28579…)

The dump we downloaded from the bucket is byte-for-byte the one the backup wrote. A truncated upload or a silently corrupted object fails here, before anything is restored.

storage file integrityall 250 files match their manifest checksums

Each backed-up file was read back out of the bucket and re-hashed. This run used the CLI's --verify-all-files, so all 250 were checked; a hosted scheduled drill samples up to 100 per drill and has no setting to raise that, so above 100 files a hosted report proves less than this run did.

sandbox extensionsinstalled pg_trgm, pgcrypto

The extensions the dump depends on were installed before restoring. Without this, an index built on a trigram operator class fails to restore and the failure looks like data loss.

pg_restorecompleted in 2.3s

Schema and data restored with no unexpected errors. A version-pinned allow-list covers the handful that are expected against any Supabase-shaped dump — “schema public already exists”, for instance — and anything outside it fails the drill. That is stricter than a bare exit code, which is never clean on a real restore.

post-data objectsuser objects restored; 5 Supabase-managed object(s) skipped (auth schema/roles do not exist in the drill sandbox)

Your indexes, constraints and triggers came back. The 5 skips are your own RLS policies, not platform objects — they reference Supabase-managed roles (authenticated, anon) that do not exist in a bare sandbox, so they are recorded as expected skips rather than quietly counted as passes.

table countrestored 13, manifest 13

The restore produced the same number of tables, partitions and materialized views the manifest recorded. Equal counts alone do not prove they are the same objects — that is the next check's job — and functions, views, sequences and types are restored but never counted here.

no missing tablesall manifest tables present

Every table recorded at backup time exists by name in the restored database.

populated tables came backno populated table restored empty

The nastiest silent failure — a restore that produces the right schema with empty tables — is caught. Tables that were already empty at backup time are not held against the drill.

app checkscommand exited 0 in 0.1s

The hook where your own assertions go: a command you supply runs on your machine against the restored sandbox, and a non-zero exit fails the drill. Ours asserts exact row counts for five of the thirteen tables plus an exact revenue total — so loss in those five fails the drill, while loss in one of the other eight would still have to be caught by the non-empty check above. Its foreign-key and materialized-view assertions are weaker still, since a completed restore re-enforces both anyway. The assertions worth putting here are the ones only your code knows, and no hosted drill runs them: --check-cmd is CLI-only.

What it does not prove

A passed drill means this snapshot restored successfully in the verification environment at that time. It is strong evidence, not an unconditional guarantee about a future restore. Specifically:

  • It is not an RLS test.The transcript says “5 Supabase-managed object(s) skipped”, which undersells it: those five are your own RLS policies, skipped because they reference Supabase-managed roles (authenticated, anon) that do not exist in a bare sandbox. They were not restored and not verified. Verifying RLS behaviour needs an authenticated client stack with real JWTs — no restore sandbox can fake that, so we do not claim it.
  • It is not a row-for-row comparison with production. The manifest’s row counts are planner estimates, so the check is “every table that had rows came back non-empty”, not “the counts match”. The --check-cmd hook above is how you add the assertions only your code can make.
  • Storage verification reads the backup copies. It compares what is in your bucket against the manifest written when the backup ran — it does not re-read the live files from Supabase.
  • One thing this fixture could not exercise. Because the source is a local Postgres rather than a hosted project, the backup could not read storage.buckets — that is the ! line in the transcript above. Bucket attributes (public flag, size limit, allowed MIME types) were recorded as absent, and a restore from this snapshot would say so rather than guess. Against a real Supabase project they are captured and rebuilt.
  • The dump covers the public schema by default. Supabase-managed schemas, Auth users, Edge Function source and project configuration are not in the snapshot and therefore not in the drill. The coverage matrix is the full list.

What changes if you run it again

We ran this three times. The distinction matters more than any single number, because it is the reason the drill measures rather than trusts:

ValueRerunsWhy
Every number published on this page: rows restored (1,495,250), table count, the invariant totals, per-file sha256StableThe drill counts rows in the restored database, and the row values and file bytes those figures depend on are generated deterministically.
The manifest's estimated row countDriftsIt is n_live_tup, a planner estimate. Across three runs of the same fixture we saw 1,495,250, then 1,745,499, then 1,495,499.
Dump sha256ChangesTwo reasons: the fixture's uuid primary keys are random and seven tables stamp now(), so the bytes genuinely differ — and pg_dump writes a creation timestamp into the archive header, so even identical data would not hash the same.
Restore secondsChangesHardware.

On one of those runs the manifest claimed 250,000 more rows than the database actually held, and the drill’s own count was right anyway. That is the whole argument for verifying a restore instead of reading a backup’s self-report.

And 2.3 seconds is hardware, not a product claim — and it times the pg_restore phase alone, not the whole recovery: downloading the snapshot and checksumming the files happen before the clock starts. A real recovery also pays for the download, and yours depends on dump size and disk. Which is exactly why it is worth measuring on a schedule instead of guessing.

Run it yourself

The fixture, the seed, the invariant script and the exact commands are in the open-source repository. It starts two throwaway fixture containers, and the drill starts a third for its sandbox, so three run at peak; all are removed when it finishes. Nothing touches a real project.

git clone https://github.com/backupdrill/cli
cd cli && pnpm install --frozen-lockfile && pnpm build
./examples/reproducible-drill/run.sh

examples/reproducible-drill → has the schema, the seed, and a note on which numbers should match ours and which cannot.

This drill was run by hand with the CLI, and two lines above depend on that: --check-cmd adds the app-checks line outright, and --verify-all-fileswidens the Storage check from a sample to all 250 files. Neither flag has a hosted equivalent. The hosted service runs the same engine on a schedule — monthly on Solo, weekly on Team and Agency, once on the Free plan’s first backup — and emails you the report, with the seven structural checks and Storage sampled up to 100 files. The value it adds is that it happens whether anyone remembers or not. Start free or read how to test a restore by hand.