3987 words
20 min read

Backups Verified by Restore — Twelve Days of OK Over an Empty Archive

By · Docker Captain · CNCF Ambassador · IBM Champion
An empty steel safe with its lid open, cold cyan light inside and one red lamp still lit

A backup that logged Data backup OK every cycle for twelve days contained none of the team’s attachments. The loop was archiving a volume nothing wrote to any more, the archive opened, and the check in CI listed it and stayed green. Forty-seven templates in the same fleet shipped restore scripts, and not one of those scripts had ever been run by a test. Every one of those loops was mine. Both facts surfaced in the same month.

A backup that is verified by restore is the only kind that exists. Everything else is an archive with a hopeful name. Health checks that cannot fail taught the same lesson one layer down: a check that has only ever been silent has not been shown to work, and a backup that has only ever been listed has not been shown to restore.

What a backup verified by nothing looks like#

Every template in this fleet runs a sidecar beside its database that dumps the database and archives the data on a schedule, prunes old archives, and logs a line. For years the loop wrote straight to the final file name, and its success condition read the exit code of the archiver:

Terminal window
# the backup loop, before, as a sketch of the shape the templates shared
# (each compose file has its own variables): the final name from the first
# byte, and an exit code as the verdict
if tar -czf "$BACKUPS/data-$(date +%F_%H-%M).tar.gz" -C /data . \
|| [ $? -eq 1 ]; then
echo "Data backup OK"
fi

The clause that accepts an exit code of one is there because GNU tar returns 1 when a file changed while it was being read, which is harmless on a live data directory. BusyBox tar, on the alpine images two in five of the fleet’s backup sidecars run, also returns 1 when it cannot open the output file at all. Blocking the destination on purpose produced two unreadable files named like backups, and the log said OK over both. Interrupted mid-dump, the same loop left a truncated file under exactly the name a restore selects.

The loop as the fleet’s rule now requires it writes to a partial name, reads the archive back, and only then gives it the name a restore would pick:

Terminal window
# the backup loop, after, as a sketch of the shape, not one template's text:
# nothing carries the final name until it has been read back
f="$BACKUPS/data-$(date +%F_%H-%M).tar.gz"
tar -czf "$f.partial" -C /data . 2>/dev/null; rc=$?
if { [ "$rc" -eq 0 ] || [ "$rc" -eq 1 ]; } \
&& tar -tzf "$f.partial" >/dev/null 2>&1; then
mv "$f.partial" "$f" && echo "Data backup OK: $f"
else
mv "$f.partial" "$f.failed" 2>/dev/null
echo "Data backup FAILED: tar exited $rc, or the archive does not open" >&2
fi

An exit code shared by a harmless case and a fatal one is not a result. Reading the archive back is.

I have carried this rule since a telecom environment where a nightly database dump had been “succeeding” for months against a disk that was full. The file was there. It was four kilobytes.

Why this keeps happening#

The success line is written by the thing that took the backup, and it describes the thing it did rather than the thing that is needed. tar reports that it archived what it was pointed at. It cannot report that it was pointed at the wrong directory. The Outline template replaced MinIO with Garage in its version 2 release, and the backup loop kept archiving the minio-data volume, which nothing wrote to from that day on. Every cycle logged OK. CI checked that the archive it named opened, which it did, because an empty volume archives beautifully. The attachments were in no backup at all, and the restore script for them looked for a service called minio and stopped before touching anything.

The second reason is that the restore is tested with a copy. Every template’s end-to-end test restored a backup on every push and went green, and every one of those tests restored with its own commands. The scripts a person would run on the day they need one had been run by nobody. Checked against their stacks on 23 September: four written for Bitnami images on stacks that run official ones, one of which refused to run at all; one that signed in with the wrong account and would have dropped a database that does not exist; restores that merged instead of replacing, through a recursive delete that keeps dotfiles, an archive extracted over live data, which deletes nothing, and a Mongo restore with the drop option, which keeps collections the archive does not hold; paths read from the operator’s shell instead of the stack; and a database image with no client of the name the script called. In sixteen templates the script was more than three years old.

The tests stayed green throughout, because the copy was right and the script was not.

Two tests were more honest than the scripts they stood beside. One cleared the data directory before extracting, with a comment explaining that tar does not delete. The other put its marker in a collection the archive already held, with a comment explaining that the drop option replaces only those. Both tests knew the limitation and worked around it. Neither shipped script did. When a test has to work around the thing under test, the workaround is the finding.

Risk and blast radius#

Direct exposure is the day the backup is the only copy. ISO 27001 control 8.13 asks for backups to be tested regularly, and SOC 2’s availability criterion A1.3 asks for the recovery plan procedures to be tested. A green run that restores with its own commands can satisfy either auditor and still fail the operator. The Outline case is the exact shape: twelve days in which any disk failure would have lost every attachment the team had uploaded since the migration, under a log that said OK once a day, twelve days running.

Systemic exposure is the copy count. These templates are deployed by people who take the backup loop and the restore script as a pair that has been proven together. Until 23 September the pair had never been proven together, and the script not at all. A defect in a shipped restore script is not one incident. It is one incident per deployment, on the day each deployment needs it, with no way to know in advance which of them will. A restore procedure handed to a customer in a runbook is the same kind of shipped script, and the same test applies: run it as written before the customer has to.

The prune is the third exposure and it points the other way. Every sidecar prunes archives older than a number of days, one find command that deletes by age. Thirteen templates shipped that line with no test of it, and one, Zammad, had carried a retention setting since the day it was written without anything ever asking whether it deleted anything, or whether it stopped at the recent archives. A prune that is only intended fills the disk. One that takes everything is a backup that is not there on the day.

What running the shipped scripts found#

On 23 September the fleet’s tests were changed to invoke the shipped restore scripts, non-interactively, against a marker written after the backup they restore. Forty-seven templates’ scripts were rewritten in one day, and six of the mistakes that come with a day like that were caught before release and listed on the evidence page. The rule that closes the class lives in the fleet’s conformance checker rather than in forty-seven workflows:

# scripts/fleet-conformance.py in fleet-ops at c06df73, reflowed to fit:
# a restore script CI does not run is a guess
scripts, unrun, looked = unrun_restore_scripts(repo, names)
if scripts:
if unrun is None:
bad.append(f"ships {', '.join(scripts)}, and nothing under tests/ "
f"or .github/workflows/ could be read to see whether CI "
f"runs them (looked in: "
f"{', '.join(looked) or 'nothing found'})")
elif repo.name in RESTORE_NOT_YET_RUN:
if unrun:
exempt.append(f"restore scripts CI does not run yet, listed in "
f"RESTORE_NOT_YET_RUN: {', '.join(unrun)}")
else:
bad.append("CI runs every restore script now: take it off "
"RESTORE_NOT_YET_RUN, so a regression here fails "
"instead of being excused")
elif unrun:
bad.append(f"CI never runs {', '.join(unrun)}: a restore script "
f"that no test invokes can be wrong for its stack while "
f"every run is green")

A comment or an echo that names the script does not count. The rule’s list of exceptions can only shrink, and it has been empty since the day it was written.

The Outline test now proves the backup the way a user would notice it missing. It stores an object through the application’s S3 interface, waits for an archive written after it, stores a second object, restores, and requires the first to read back and the second to be gone:

Terminal window
# tests/e2e-backup-restore.sh in the Outline template at v2.2.8, shortened
# and reflowed to fit: prove the backup the way a user would miss it
tag="e2e-$(date +%s)"
if ! s3obj put "$tag/before.txt" "stored before the backup"; then
bad "could not store an object through S3"
else
# ... Garage is asked for its metadata snapshot rather than waited for
ol_sh "touch $OUTLINE_DATA_BACKUPS_PATH/.e2e-stamp"
archive=$(newest_after_marker "$OL_BK" "$OUTLINE_DATA_BACKUPS_PATH" \
"$OUTLINE_DATA_PREFIX" ".tar.gz")
if [ -z "$archive" ]; then
bad "no attachment archive taken after the object within ${CYCLE_WAIT}s"
else
note "archive: $(basename "$archive")"
s3obj put "$tag/after.txt" "stored after the backup" \
|| note "could not store the second object"
if ! COMPOSE_PROJECT_NAME="$PROJECT" \
./outline-restore-application-data.sh "$(basename "$archive")"; then
bad "./outline-restore-application-data.sh failed"
else
waited=0
until got=$(s3obj get "$tag/before.txt" 2>/dev/null) \
|| [ "$waited" -ge 120 ]; do
sleep 3; waited=$((waited + 3))
done
if [ "$got" = "stored before the backup" ]; then
ok "the object stored before the backup reads back through S3 …"
else
bad "the object stored before the backup is missing after the restore …"
fi
if s3obj absent "$tag/after.txt"; then
ok "the object stored after the backup is gone, so the restore …"
else
bad "the object stored after the backup survived the restore"
fi
fi
fi
fi

The second object is the half people skip. A restore that leaves newer files in place is a merge, and a merge passes every test that only asks for the old marker back.

The prune got the same treatment two days later, after a good Nextcloud upgrade was reverted twice by a test that slept for a number. The test waited the backup interval plus sixty seconds and then asked whether a file dated 2020 was gone. With the new release the data backup took 98 seconds, the cycle ran 160, and the test asked at 120, before the prune had run. Thirty-six templates carried the same three lines. The test now waits for the event it is testing, with a ceiling of two cycles, and prints how long the prune took:

Terminal window
# tests/e2e-backup-restore.sh in the Keycloak template at v1.8.8, shortened
# and reflowed to fit: wait for the prune itself, not for a number chosen
# when the data was small
local ceiling=$(( secs * 2 + 300 )) waited=0
echo " waiting up to ${ceiling}s for a prune cycle to remove it..."
while backups_sh "ls $fake_old 2>/dev/null" > /dev/null 2>&1; do
if [ "$waited" -ge "$ceiling" ]; then
fail "fake old file survived ${ceiling}s, longer than two backup cycles"
return 1
fi
sleep 5; waited=$(( waited + 5 ))
done
echo " pruned after ${waited}s"

A file dated 2020 placed beside Zammad’s real backups was gone after the next cycle and six recent archives stayed. It was the first time that sentence could be written about that template.

A restore can put back every byte and still lose the service, because the bytes land in the wrong place or belong to the wrong user. October produced one of each, and both were caught by a restore that checked the service, not the exit code.

On a VEIN game server I run, the world is backed up with restic, which recreates a snapshot’s original path under the restore target. A plain restore of the latest snapshot would have put the world one directory too deep, where the server does not look, and the server would have started a new world over it. A test restore caught it before anyone needed the backup. Restic’s own form for restoring the contents of a path, the snapshot, a colon and the path, put the world file back byte for byte at the path the server reads. The public VEIN template archives with tar rather than restic, and its test guards the same class of mistake: it asserts places and bytes, not exit codes, including the contrast case, in which the same archive extracted at the volume’s root must leave no world where the server looks.

The second never reached a user. The fleet’s shared restore pattern sets aside what the archive leaves out on purpose, clears the volume, unpacks the archive, and puts the set-aside paths back. For months it did the last step in one line:

Terminal window
# the shared restore pattern, before, as it runs with the data root at /data:
# the set-aside paths put back in one copy
cp -a /tmp/restore-keep/. /data/

Copying a directory’s dot entry in archive mode copies the directory itself. Its owner and mode land on the target. The volume’s root came back owned by root, and the restored database was given that owner. AnythingLLM runs as an unprivileged user. After the restore it passed every read. Every write failed: attempt to write a readonly database. The archives record the moment: sets taken before the first restore show the volume’s root owned by user 1000, sets taken after it by root. It surfaced on 19 October, in the first local runs of the template’s restore test, before its first release, and the test has asserted a write after every restore since. The fix puts the set-aside paths back one by one and gives the volume’s root the owner of the files the application created:

Terminal window
# anythingllm-restore-data.sh in the AnythingLLM template at v1.0.0,
# lines 98 to 104, without the escaping of the sh -c string they run in:
for p in $KEEP; do
[ -e "/tmp/restore-keep/$p" ] || continue
rm -rf "$p"; mkdir -p "$(dirname "$p")"; mv "/tmp/restore-keep/$p" "$p"
done
rm -rf /tmp/restore-keep
owner=$(stat -c %u:%g /data/comkey 2>/dev/null || stat -c %u:%g /data)
chown "$owner" /data

Since the next release, the set-aside paths wait in a hidden directory on the volume itself rather than in the container’s temporary directory, so each move is a rename and nothing is copied into the container’s layer.

The same line was in seven other templates, and none of them broke as shipped, because their applications run as root, take ownership of their data on every start, or restore into a directory root already owns. A line that is harmless seven times and fatal the eighth is invisible to a test that only reads.

Framework: prove the archive, prove the script, prove the prune#

Three things have to be shown to work, and each is a separate test with a separate owner. A stack that has one of them has one third of a backup.

Layer 1: the archive is read back before it gets its name (week 1)#

Nothing carries the name a restore would pick until it has been opened. For a compressed dump that is an integrity test of the compressed file; for an archive it is a full listing of its contents; for a database it is the dump loaded into a throwaway container and compared with the live schema, table for table, because a floor of “more than ten tables” passes a dump that restored a fifth of the schema and keeps passing as the application grows away from it:

Terminal window
# restore-drill.sh in restore-drill at v1.1.0, shortened and reflowed to fit:
# the live database is the reference, not a number in the test
# ... table_list <container>: one table name per line, sorted,
# empty on any failure
if [ -n "$DRILL_LIVE_CONTAINER" ]; then
live="$(table_list "$DRILL_LIVE_CONTAINER")"
# An unreachable live database answers the same as one with no tables, and
# reading that as "nothing is missing" would turn this into a check that
# cannot fail — the exact shape this whole tool exists to refuse.
[ -n "$live" ] \
|| die "could not read the table list from $DRILL_LIVE_CONTAINER — …"
restored="$(table_list "$CONTAINER")"
missing="$(comm -23 <(printf '%s\n' "$live" | sort -u) \
<(printf '%s\n' "$restored" | sort -u))"
if [ -n "$missing" ]; then
count="$(printf '%s\n' "$missing" | wc -l | tr -d ' ')"
die "$count table(s) the live database has did not come back: $(
printf '%s' "$missing" | tr '\n' ' ' | cut -c1-300)"
fi
# ... the line that reports the drill, and the table floor used only
# when no live database is named

The fleet’s conformance rule now fails a sidecar that writes straight to the final name, one whose success condition tests the final name, and a tar archive renamed on the exit code alone. Checking this post found the rule’s blind spot. It recognised a backup loop by the tool that wrote it, and nine templates wrote theirs with a tool it did not know or with tar’s options in another order: six copied their SQLite databases with SQLite’s own online backup and then archived, two archived with the directory option first, one used SQL Server’s own backup command. It reported all nine clean without reading them. A rule that cannot see a loop passes it. The nine were fixed and released first, each archive named only once it lists cleanly and the SQL Server backup only once the server has verified it. Then the rule learned to read every loop, shown to fail on a planted case before it passed, and the fleet’s next conformance run found every repository meeting it.

Reading back proves the archive opens. It does not prove the archive holds anything anyone needs: an archive of an empty volume opens perfectly, which is why the Outline case was caught by the restore in the next layer and not by this one. Tools built for backup ship this layer as a command, restic check with its read-data option and borg check with verify-data, which read the stored data back; a hand-written loop has to add it.

Owner: the maintainer of the stack.

Layer 2: the test runs the shipped restore script against a marker written after the backup (week 2)#

The script takes the backup file name and its confirmation on standard input, or as an argument where the script has no prompt, and reads every path, name and credential from the running backups container, whose environment the backup loop itself reads, so the two cannot disagree. The test writes a marker, waits for a backup whose cycle started after it, destroys the live state, runs the shipped file, and requires the marker back through the application, not through the database:

Terminal window
# tests/e2e-backup-restore.sh in the Keycloak template at v1.8.8, reflowed
# to fit: the shipped file, its two prompts answered on stdin, the file name
# and then DESTROY
if ! printf '%s\nDESTROY\n' "$(basename "$baseline")" \
| COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT_NAME" \
./keycloak-restore-database.sh; then
fail "the shipped restore script failed"
return 1
fi

Where the test has to work around the script to pass, the workaround moves into the script and the test is made to fail the old version. And once the restore is done, the test asks the application to write, because a restore that hands the data to the wrong owner passes every read:

Terminal window
# tests/e2e-backup-restore.sh in the AnythingLLM template at v1.0.0, reflowed
# to fit: a restore that reads is not yet a restore that works
wrote="$(api /api/v1/workspace/new -H "Content-Type: application/json" \
-d '{"name":"after-restore"}' \
| python3 -c 'import json,sys
print((json.load(sys.stdin).get("workspace") or {}).get("slug") or "")')"
[[ -n "$wrote" ]] || {
fail "AnythingLLM cannot write after the restore (a new workspace was refused)"
return 1
}

Owner: platform engineer.

Layer 3: the prune and the clean machine, on a schedule (week 3 onward)#

The prune test above runs on every push. The clean-machine drill runs weekly and restores the previous release’s backup into the current release on a runner that has never seen the stack, which is the only test that finds what the dump does not carry. And the conformance rule that ties the three together runs daily across every template, planted both ways in the fleet’s own suite:

# scripts/fleet-conformance.py in fleet-ops at c06df73, reflowed to fit:
# a prune that is only intended is not a prune
prunes = [n for n in composes
if re.search(r"PRUNE_DAYS|HOLD_DAYS|-mtime", repo.read(n) or "")]
if prunes:
tests = [f"tests/{n}" for n in repo.names_in("tests") if n.endswith(".sh")]
exercised = any(re.search(r"(?im)^(?!\s*#).*prun", repo.read(t) or "")
for t in tests)
if tests and not exercised:
bad.append(f"{', '.join(prunes)} prunes old backups and no test "
f"under tests/ exercises the prune: a prune that is only "
f"intended fills the disk, and one that takes everything "
f"is a backup that is not there on the day")

Since 19 October the same daily rule also fails a restore script that copies a directory over the data root, and its tests plant the line in code, where it must be caught, and in a comment, where it must not.

Owner: SRE, with the rule’s exception list reviewed the day it stops being empty.

Tradeoffs#

Deployment verification grew from a boot check to a job of about seven minutes on every push, ten for the heaviest stack, and the sidecar had to learn to tolerate files changing under it while it archived. The restore rewrite cost a day for 47 templates and produced six mistakes of its own before release, each caught by the tests the rewrite had just made honest.

Skipping it costs what the Outline case cost: a window, twelve days here, in which the backup exists, reports OK, and holds nothing you would want back. Nobody finds that window until the day they need the backup, which is the one day it cannot be fixed.

Seven minutes a push, against an archive of a volume nothing writes to. That is not a close call.

The closing argument#

An archive that opens proves the archive. A restore that runs with its own commands proves the copy. A prune that is only intended is not a prune. The backup that exists is the one a test has restored, with the shipped script, on a machine that did not have the data, then found the marker through the application and written something new. Everything short of that is a log line that says OK, and the log line is written by the thing that took the backup, not by the thing that would need it.

Sources#

Discussion#

If you have found a backup that reported OK over the wrong directory, or your restore test runs the shipped script rather than a copy of it, 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  ·  CNCF Ambassador  ·  IBM Champion

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
    Install ownCloud Using Docker Compose
    Self-Hosting · Learn how to install ownCloud with Docker Compose on Ubuntu using Traefik and Let's Encrypt. Secure, scalable file storage and sharing for your server.
  2. 2
    Install Docmost Using Docker Compose
    Self-Hosting · Learn how to install Docmost using Docker Compose with Traefik and Let's Encrypt. Step-by-step guide for self-hosting a modern documentation platform.
  3. 3
    Install AFFiNE Using Docker Compose
    Self-Hosting · Step-by-step guide to install AFFiNE using Docker Compose with Traefik and Let's Encrypt. Build your open-source productivity platform in minutes.
  4. 4
    Install Homebox Using Docker Compose
    Self-Hosting · Step-by-step guide to install Homebox with Docker Compose and Traefik. Secure your home inventory system with HTTPS using Let's Encrypt.

Random Posts

Random
  1. 1
    Docker Desktop's Performance Odyssey Over a Year of Innovations
    Opinion & Culture · Docker Desktop's 2023 performance work, read against Docker's and Ajeet Raina's published numbers with attribution, plus my own October 2026 rerun on an M4 Pro.
  2. 2
    Cheburnet as the Fortress of Lies and Censorship in Russia
    Opinion & Culture · The tightening of internet censorship in Russia, including blocking access to social networks and VPNs, illustrates the growth of isolation and control.
  3. 3
    Install AWS CLI on macOS
    DevOps & Cloud · Step-by-step guide to install AWS CLI on macOS using the terminal. Learn how to download, install, and verify AWS CLI in minutes for seamless cloud management.
  4. 4
    Install Windows Server 2016
    SysAdmin & IT Pro · Learn how to install Windows Server 2016 step by step using official media. Get a stable, secure setup with the Desktop Experience.
Backups Verified by Restore — Twelve Days of OK Over an Empty Archive
https://heyvaldemar.com/backup-verified-by-restore-ci/
Author
Vladimir Mikhalev
Published
2026-10-30
License
CC BY-NC-SA 4.0