Applies to
ops.backup-coverage, ops.recovery-drill.
Covers ops.backup-coverage, ops.recovery-drill. Related: ops.db-write-backup,
ops.deploy-target-pinned, env.no-production-data-off-production.
A backup nobody has restored is a hypothesis, and a real outage is the worst possible moment to test it. These two rules split the problem into the half you can schedule and the half you have to actually do once, on purpose, before launch.
Why the drill is a rule and not advice
Every other rule in this dimension checks that a mechanism is configured. The drill is the only one that checks a mechanism works, which is why it survived into the standard when the client checklist did not ask for it: item 15.6 asks for "uptime monitoring and backup schedule active", and a schedule is a claim about the future. A restore is a fact about the past.
What has to be in it
A WordPress site is four things and only two of them are this rule's problem.
| Part | Where it lives | Covered by |
|---|---|---|
| Content, options, users | the database | the daily dump |
| Media | wp-content/uploads |
the weekly file copy |
| Theme and plugin code | the repository | git, plus env.deploy-provenance to say which commit is live |
| Field definitions | the repository, per admin.fields-in-code |
git |
The bottom two rows are why the code half does not need a backup. The top two have no other copy anywhere in the world.
Why the uploads get forgotten
The database feels like "the site" and a dev workflow touches it daily. Uploads get pulled once at the start of a project and never thought about again. Three specific ways that ends badly:
- A sync is not a backup.
rsyncor anuploads:pullcommand mirrors the source, deletions included. Delete a folder on production and the next sync deletes your copy. Both delivered builds audited for this standard have exactly this shape: a working uploads sync, and no retained uploads backup at all. - The restore looks fine. Import the database and every post renders, the media library shows a full grid, the admin lists every attachment. The grid is broken thumbnails. The metadata is in the database; the bytes are not.
- Uploads is not just originals. Optimiser derivatives live there, and so does the
.htaccessrewrite that serves them, a real file insidewp-content/uploadson both reference builds. Restore the originals alone and the rewrite points at nothing. Regenerating thumbnails gets the derived sizes back; nothing regenerates an original.
The backup on the same disk
Both reference builds keep their dumps in a directory inside the site's own hosting account. That is not a backup of the site. It is a second copy of the site's worst day.
Each of these takes the site and the backups together, in one event: the account is suspended for
non-payment or an abuse report; the account is compromised, and deleting the backups is step two of
any competent ransom; the disk fails; or an rsync --delete lands on the wrong target, which is
ops.deploy-target-pinned, and one of the two builds has no target pin at all. The panel's own
backup button is on that list too, because it belongs to the account it is protecting. One copy
held outside the hosting account answers all four, which is the entire reason that measurable is in
the rule.
Two more things the measurable quietly buys. Keep the dump out of the document root, since a
.sql.gz under the web root is served to anyone who guesses the filename. And make retention a
number of days: a bad edit or a compromise is normally found days later, so a single file
overwritten nightly faithfully replaces your last good copy with a corrupt one. The newest
restorable copy on one of the two audited hosts was a single file, 29 days old.
Running the drill
# 1. Take the newest copy from OUTSIDE the hosting account, not the one on the box.
# 2. Prove it is a dump before trusting it. A failed dump is still a valid gzip.
gzip -t db-2026-08-24.sql.gz
zcat db-2026-08-24.sql.gz | tail -1 # a finished mysqldump ends with a completion line
# 3. Restore into a target that is not production.
wp db import db-2026-08-24.sql
rsync -a uploads-2026-08-24/ wp-content/uploads/
wp search-replace 'https://www.example.com' 'https://drill.example.test' \
--all-tables --skip-columns=guid
# 4. The line that settles it.
curl -o /dev/null -s -w '%{http_code}\n' https://drill.example.test/
Keep the terminal output, dated, and the wall-clock time with it. The accept is written so a tick cannot satisfy it, because nothing in a theme performs this - and the elapsed time is the only honest input to any answer you give a client about how long they would be down.
The drill fights a blocker, on purpose
env.no-production-data-off-production is a blocker: production submission and consent rows do not
travel to staging or a laptop. A restore drill takes a production backup and puts it somewhere that
is not production, which is that exact thing.
They are not in conflict, but the order matters, and this ordering is the standard's own position rather than anything a source says. The target is throwaway, created for the run. Prove the restore first, because a drill that anonymises before it proves the restore has tested the anonymiser rather than the backup. Then destroy the target in the same session: what survives is the output, not the data. Staging is never the drill target, being long-lived and logged into, which is exactly what the blocker is about.
The rollback half has to exist before launch
The rule asks for two runs, and the second - one deploy back to the previous release - is where
both reference builds are structurally unable to comply rather than merely undone. Both deploy with
rsync --delete straight into the live theme directory: no releases directory, no symlink to
switch, and the built dist/ gitignored. So rolling back is not a checkout and a redeploy, it is
rebuilding an older commit, needing the right toolchain and a lockfile that still resolves, during
an outage, on a day that is already bad.
Deploying into a timestamped releases directory and switching a symlink costs an afternoon before
launch. Retrofitting it costs a maintenance window and an awkward conversation.
env.deploy-provenance is the other half: if the live site cannot name its commit, a rollback
restores something that was never live.
What the sources actually say
Both citations are partial, like 99 of this standard's 100. The hardening handbook asks for "regularly-timed snapshots ... in a trusted location" and for backups that are "tested" and "rehearse restore procedures". So the ideas are not ours; every number is. Daily, weekly, retention in days, the off-account copy, the seven-day freshness, the non-production target, the 200 on the home page and the deploy rollback are this standard's, and the handbook mentions the rollback nowhere. Neither rule is a client's own legal obligation. Do not quote them as one.
Where this stands
Both rules are aspirational against the reference builds: no database backup under 24 hours, no
file backup under 7 days, automatic dumps only inside interactive commands so nothing runs unless
somebody runs it, and no evidence of a restore drill in either repository. Nothing in
shipped.yml gets you this by importing it. This standard's own site is unverified on both.
One of the two builds already lost production content once, and its sync script carries a comment naming the code path that did it. The recovery worked. The comment was written after the loss rather than before it, which is the normal order, and the drill is the rule that exists to change it.