2812 words
14 min read

Restore Drills on a Clean Machine — Measured RTO for 47 Stacks, 19 Seconds to 7 Minutes

By · Docker Captain · CNCF Ambassador · IBM Champion
Cyan-lit server slab rebuilt from a red-marked archive, restore drill

Every same-host restore of the Nextcloud template passed, on every push, for three weeks. Restored onto a machine that had never run the stack, it answered 500 on every request. The database dump did not carry the account the application logs in with, and only a clean machine could have said so. I have run that restore drill, a disaster recovery test on a clean machine, weekly since 24 September on 47 Docker Compose templates, from the previous release into the current one, and the clock stops when the application answers over HTTPS with both markers back.

The same-host restore is the drill most teams run, when they run one at all, and it answers an easier question than the one a security questionnaire asks. The questionnaire asks for the recovery time objective, how long until the service works again after the host is gone, and whether a backup taken under last month’s release restores into this month’s. A restore onto the host that took the backup answers neither. It is the pattern from health checks that cannot fail, one layer further out: a check that has only ever passed has not been shown to work.

What a restore drill that proves the archive looks like#

The drill every template carried until 24 September was a real test, and it was wrong in one word. It restored on the same runner, minutes after the backup, into the same containers. Its shape, shortened to the lines that matter:

# deployment-verification.yml, before: backup and restore on one runner
- name: Backup and restore end-to-end
run: |
./tests/e2e-backup-restore.sh # marker in, backup, destroy, restore, marker back

The marker came back every time. So did the database account, the encryption keys in the environment, the files an installer had written on first boot, and every other thing the host still had because the host was still there. The test proved the archive. It did not prove the procedure an operator would follow at three in the morning with a new machine and a directory of backup files.

The drill that replaced it runs on two runners that never meet, and the second one has never seen the stack:

# dr-drill.yml, shortened: the host that dies, then a machine that has never run the stack
jobs:
before:
name: The host that dies runs the previous release and is backed up
runs-on: ubuntu-latest
steps:
- run: ./tests/dr-drill.sh before
- name: Keep only what an operator keeps off the host
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dr-offsite
path: dr-out
after:
name: A clean machine brings the current release back from those files
needs: before
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: dr-offsite
path: dr-out
- run: ./tests/dr-drill.sh after

The first job starts the previous release and writes a marker row into the database and a marker file into the data. It waits for a backup whose cycle began after both, then exports only what an operator keeps off a host: the backup files and the environment file. The second job starts the current release empty on a fresh virtual machine, waits until it answers, copies the backup files in, runs the shipped restore scripts, and requires the application to answer over HTTPS with both markers back. The time from the first command to that answer is the number.

In a Series D environment I have watched a quarterly restore drill pass for a year on the host that took the backups, and fail the first time the host was gone. The archive was fine. The runbook was not.

Why this keeps happening#

NIST SP 800-34 defines the recovery time objective as the maximum time a system can be down before the outage compromises the mission. Its test section names the case directly: system recovery on an alternate platform from backup media, in as close to an operating environment as possible. Most drills stop short of it. A test on the host that took the backup is easier to schedule, and it answers a different question. That host still holds everything the backup left out: server accounts, keys the installer generated, files written on first boot, and a running application that was never actually gone.

Nextcloud is the clean example. Its installer creates a database account with a random password and writes it into its configuration file. A dump saves the data and none of the server’s accounts, which is what a dump is for; the textbook answer is a second dump, of the server’s roles, kept beside it. On the same host the account exists with the right password, so every restore passes. On a new host the installer creates the account again with a different password, the restored configuration carries the old one, and every request answers 500 with password authentication failed for the application’s database user. Both restore scripts here take the other road and bring the account in line with that file, as the installer would, and the clean machine is the only test that can tell whether that line is still there.

The second reason is that the new host is not idle. Its backup loop starts with the stack, and with CI’s fifteen-second warm-up the first cycle wrote an empty backup into the same directory before the restore ran. A restore that picks “the newest file” picks that one. Nextcloud got an empty database next to the old files; Gitea came back without its files. File names carry the minute, not the second, so the new host’s first cycle can even overwrite an imported backup of the same name.

A restore onto a rebuilt host restores by name, from the files you brought, never by newest.

Risk and blast radius#

Direct exposure is the questionnaire. Every enterprise security review I have filled in asks for the recovery time objective and how it was measured, and ISO 27001 control 8.13 asks for backups to be tested regularly. A same-host restore answers both with a number that is true on the day nobody needs it. The number that matters is measured from an empty machine, and until it has been, the field on the form is a belief. The question for whoever signs the questionnaire is on which machine the RTO was measured.

Systemic exposure is the fleet. These 47 templates are copied into other people’s hosts, and each carries its own restore scripts. Until 23 September no test had run those scripts; each test used its own copy of the commands, so eight of them disagreed with the stack they belonged to and four had been written for a different vendor’s image. A restore script CI has never run is a guess with a shebang line, and a guess copied into a hundred deployments is a hundred guesses.

The drill’s own history is the third exposure. It took five attempts to make honest. The first crossed from version 1.9.1 to version 1.9.1, because “the release before main” by tag order was the current one when main was one commit past the tag. One raced its own backup loop. One accepted a backup that finished after the markers but had started before them, so it was newer than the stamp and held no marker. One waited fifteen minutes for a SQL Server dump that landed every sixty seconds, because its file pattern began with a dash and grep read the pattern as an option. One wrote its marker file into a directory the previous release did not have, and the write was allowed to fail without a word, so the drill failed twenty minutes later blaming the restore.

A drill that cannot fail on its own mistakes is a drill that reports the number you hoped for.

What 47 drills found in their first week#

The first full week measured 47 of 47 templates restoring, from the previous release into the current one, on a machine that had never run the stack. The evidence page reads the result of every drill from the fleet’s own file; a failing drill is listed as failing and carries no time. The figures in the title are that first week’s, as the catalog recorded them on 26 September; the page shows the latest week, and its numbers move.

ResultValueTemplate
Fastest19 sSiYuan, v1.6.2 into v1.6.4
Median83 sacross 47 templates
Slowest396 sJira, v1.7.6 into v1.7.8
Passed47 of 47week of 25 September 2026

The number is not the interesting part. What the drill found on the way to it is.

Three templates failed because the clean machine was cleaner than any real machine. Authelia’s Postgres refused to initialise, Dozzle could not write a password hash, Audiobookshelf’s permission job exited 1. Each of them prepares something before the stack starts: a directory of secret files, a bcrypt hash made by a documented command, a bind directory handed over to the container’s user. CI did those in steps the drill had not copied. The drill generator now borrows every step between the network creation and the start-up from the deployment workflow, and a per-template keep list names what has to travel off the dying host besides the backups and the environment file. For Authelia that is its secrets directory, because the encryption key in them is what the restored database was written with.

Mailu was red on one cold start in three, and I published a cause the same hour that the next run disproved. The fault was there before any restore: two Traefik routers with the same host rule and no priority, so Traefik chose between them at every start, and when it chose the webmail router every path on the mail host went to Roundcube. The drill now proves the empty stack answers before it puts anything back, so a start-up fault and a restore fault are two different log lines. The webmail router is gone; front serves that path itself.

OTRS stayed unhealthy on two attempts in six after the restore. Each restore script stops and starts the application, and two stop-starts seconds apart left Apache on a stale pid file. An operator waits for the first restore to come up before starting the second, and the drill now waits with them.

Framework: inventory, two runners, a published number#

The drill is one script and one workflow, generated for each template from its own compose file. The three layers below are what any stack needs before the number it produces means anything.

Layer 1: write down what leaves the host (week 1)#

The list of what an operator keeps off a host is the specification of the drill, and most stacks have never written it down. For these templates it is the backup files, the environment file, and a per-stack list of secret paths. The drill exports exactly that and nothing else, so anything the restore needed from the old host and did not get is a finding rather than a silent rescue:

Terminal window
# tests/dr-drill.sh, before: export only what an operator keeps off the host
say "exporting what an operator keeps off the host: the backup files and .env"
cp .env "$OUT/env"
for v in $(dirs); do
dir="$(env_of "$v")"
mkdir -p "$OUT/$v"
docker cp "$(cid "$BACKUPS_SVC"):$dir/." "$OUT/$v/"
done
for k in ${DR_KEEP:-}; do
mkdir -p "$OUT/keep/$(dirname "$k")"
cp -a "$k" "$OUT/keep/$k"
done
printf '%s\n' "$MARK" > "$OUT/marker"

Every path in it comes from the running backups container’s environment, the same variables the backup loop reads, so the drill and the loop cannot disagree about where the files are.

Owner: the maintainer of the template, with the operator who would do the restore reviewing the list.

Layer 2: a backup that contains the markers, restored by name on a fresh machine (week 2)#

Two rules carry the whole drill. The backup must be one whose cycle started after the markers were written, not one that finished after them, because a data archive that takes minutes can finish after a marker it never saw. The cycle’s start is in the file name, so the check compares minutes:

Terminal window
# tests/dr-drill.sh: a backup that finished after the markers is not one that contains them
wait_backup_started_after() { # directory variable, file regex, marker minute, log word
local dir pat f waited=0
dir="$(env_of "$1")"; pat="$(expand "$2")"
while [ "$waited" -lt 900 ]; do
for f in $(bk "ls -1 '$dir'" | grep -E -e "$pat" | sort -r); do
[ "$(printf '%s' "$f" | cycle_of)" \> "$3" ] || continue
if docker logs "$(cid "$BACKUPS_SVC")" 2>&1 | grep -qiF "backup OK: $dir/$f"; then
say "backup started after the markers: $f"; return 0
fi
done
sleep 5; waited=$((waited + 5))
done
echo "no $4 backup started after $3 within 900s in $dir (matching $pat)" >&2
return 1
}

And on the clean machine the restore names its file. The newest of the files brought from the dead host, by name, because a file this machine wrote itself is not a candidate:

Terminal window
# tests/dr-drill.sh, after: the file you brought, never "the newest in the directory"
newest() { # the newest exported file matching a pattern, by name: the names carry the time
local pat; pat="$(expand "$2")"
find "$OUT/$1" -maxdepth 1 -type f -printf '%f\n' | grep -E -e "$pat" | sort | tail -n 1
}
dbf="$(newest "$DB_DIR_ENV" "$DB_FILE_MATCH")"
F="$dbf" S="$(printf '%s' "$dbf" | cycle_of)" bash -c "$DB_RESTORE"

The restore command is the shipped script, with the file name as its argument. A test that runs a copy of the script’s commands proves the copy.

Owner: platform engineer.

Layer 3: publish the number, and refuse a page without it (week 3 onward)#

Each drill writes one row, and the fleet’s catalog collects them into the file the website reads:

{
"repository": "nextcloud-traefik-letsencrypt-docker-compose",
"from": "v2.0.4",
"to": "v2.0.7",
"ok": true,
"seconds_total": 154,
"seconds_restore": 87,
"backup_interval": "24h",
"rpo_hours": 24,
"run": "https://github.com/heyvaldemar/nextcloud-traefik-letsencrypt-docker-compose/actions/runs/36088409515",
"finished_at": "2026-09-25T03:06:37Z"
}

The row carries both ends of the crossing, so a drill from a release into itself is visible, and the backup interval the template ships with, which is the bound on how much can be lost. A failed drill is a row with ok false and no time, and the page shows it that way. On 26 September a partial read of the fleet published no restores at all for an hour, and the page’s browser refresh replaced its numbers with the smaller ones; the file is now refused when it carries fewer than half of last week’s drills.

Owner: SRE, with the number on the page they would be asked for.

Tradeoffs#

The drill costs four to five minutes of runner time per template per week, and thirteen for the slowest, Jira. Most of it is waiting for a real backup cycle on the dying host and for the application to come up twice on the clean one. On public repositories that is free. On private ones the 47 templates would come to roughly 230 minutes a week, about a thousand a month, half of a Free plan’s 2,000 included minutes. A larger private fleet would drill a rotating subset rather than everything at once.

A single host with no CI can run the smallest version of the same idea. restore-drill restores the newest database dump into a throwaway container on a timer and counts what came back, which catches a dump that will not load or loads empty, though not an application that cannot log in to what came back.

The alternative is the number you already have, measured on a machine that still had everything the backup left out. Nextcloud’s would have been correct on every host except a new one.

Five minutes a template a week, against a 500 on the day the host is gone. The minutes are cheaper.

The closing argument#

A backup is proven on a machine that has never seen the stack, from the previous release into the current one, by the scripts the operator would run. Anything the application needs from outside what the dump saves, server accounts, keys, first-boot files, is either in the export or recreated by the restore, and only the clean machine tells you which one you forgot. The same-host drill is not wrong. It is a test of the archive, and the archive was never the thing that failed.

Sources#

Discussion#

If you measure recovery time on a machine that has never run your stack, or you have a reason to trust the same-host number, drop a comment below. Counterarguments are welcome and the comment thread is where I respond first. For longer back-and-forth with senior practitioners, join the discussion on Discord.


Vladimir Mikhalev

Docker Captain  ·  IBM Champion  ·  AWS Community Builder

The Verdict — production-tested analysis on YouTube.

The Verdict

Inconvenient truths about shipping in the AI era

Container security, platform engineering, and the agentic shift — tested in production, argued without the hype. The verdict reaches your inbox the moment there's one worth sending.

Related Posts

Same category
  1. 1
    Mutation Testing for Infrastructure Code — 153 Planted Bugs, Four Tests That Could Not Fail
    DevOps & Cloud · Mutation testing for infrastructure code, done by hand across fifteen repositories. 153 planted bugs, and four green tests that could never have failed.
  2. 2
    Docker Health Checks That Cannot Fail — Seventeen Hours Green
    DevOps & Cloud · A Docker health check whose pattern matches the shell running it can never go red. How to find the checks in your fleet that never failed, and prove each one.
  3. 3
    Terraform MCP server GA: the Apply Gate your auditor will ask about
    DevOps & Cloud · HashiCorp's Terraform MCP server is GA and IBM Bob can write production IaC. ENABLE_TF_OPERATIONS separates a safe assistant from autonomous apply.
  4. 4
    Docker supply chain hardening — from Scout D to OpenSSF 7.8 on a 730K-pull image
    DevOps & Cloud · How I hardened a 1M-pull public Docker image from Scout grade D to OpenSSF Scorecard 7.8 — multi-stage build, cosign, SLSA provenance, non-root default.

Random Posts

Random
  1. 1
    Install and Configure DHCP Server on Windows Server 2012 R2
    SysAdmin & IT Pro · Step-by-step guide to install and configure a DHCP server on Windows Server 2012 R2. Learn to assign IPs, set exclusions, and reserve addresses with ease.
  2. 2
    Install WordPress Using Docker Compose
    Self-Hosting · Install WordPress with Docker Compose on Ubuntu using Traefik and Let's Encrypt. Full guide with step-by-step setup, HTTPS, and Docker networking.
  3. 3
    Install Firmware for Kernel Drivers on Ubuntu
    SysAdmin & IT Pro · Learn how to install firmware for kernel drivers on Ubuntu using .deb packages to ensure hardware compatibility and system stability.
  4. 4
    Configure Exchange Server 2010
    SysAdmin & IT Pro · Complete guide to configuring Exchange Server 2010. Learn mailbox setup, certificates, DNS, email policies, and secure mail delivery—step by step.
Restore Drills on a Clean Machine — Measured RTO for 47 Stacks, 19 Seconds to 7 Minutes
https://heyvaldemar.com/restore-drill-clean-machine-recovery-time/
Author
Vladimir Mikhalev
Published
2026-10-13
License
CC BY-NC-SA 4.0