For weeks the overnight backup report for the client sites I look after was a wall of green. Backed up, backed up, backed up, every site, every night.
Then I opened one of the archives. The row said completed, type full, and the zip was 6 MB. A real backup of that site is around 600 MB. Another row said the same thing over 13 MB, where the day before had been 5 GB. Inside each one there was exactly one file: db.sql. The database, and nothing else. No uploads, no plugins, no themes.
A database with no files isn't a backup of a website. It's a list of what used to be on it.
On September 27 I spent the day building guards so that couldn't happen again, and went to bed. The next morning three sites were failing.
Why the log said success
The backup works by asking the site for its whole file tree as one long tar response, and one client's host cuts any response at 300 seconds. That site's tar is 1.98 GB and crosses the wire uncompressed at about 6.5 MB/s on a good day, so it does not fit. Where the cut landed decided which of two wrong things happened.
Cut near the start. The files phase caught the error, deleted its temp tar, and fell through. The assembly step only bailed out when both the database and the files were missing, so it zipped the dump on its own, wrote status: completed and type: full, and logged success.
Cut later. tarfile does not raise on a truncated archive. It stops at the cut and hands back whatever members it managed to read, so a short archive looks like a whole one. One night's run came out at 77.8 MB with every plugin present and not a single upload.
The misleading part is that the log status was inverted against the archive. Eight nightlies in a row on that site: the four that produced a database-only stub all read success, and the nights that actually produced a real 231 MB and 578 MB archive read failed, because the zip write threw after most of it was already on disk. Reading the reports screen to find out whether a site was backed up gave you the opposite of the truth, four days running.
Uploads is always what goes missing, and that isn't luck. The walk goes wp-admin, wp-includes, wp-content, root files, and scandir inside wp-content is alphabetical, so uploads is very nearly last. It is also 78% of the payload. The time budget runs out somewhere inside uploads, every time.
The three guards
All three live in services/backup_runner.py and none of them touch the site.
- The downloaded tar has to end in a proper end-of-archive marker (two zeroed 512-byte blocks) or the files phase counts as failed. This is the one that catches silent truncation, since
tarfilenever will. - A backup that asked for files and got none fails, instead of assembling the database alone under the name of a full backup.
- Assembly that throws deletes its own half-written archive instead of leaving it in the folder looking like a backup.
Four regression tests cover both failure shapes and both good paths.
The truncation itself is still there. The guards only make it loud, and that is why three sites went red the morning after: those nights had always been failing. If I revert the guard, the stubs go back to reading as completed full backups. The failures it surfaces are real. The silence was the bug.
The 76 rows that were already lying
The guard stopped new stubs from being written. It didn't go back. So the app went on listing every earlier one as a success, which is how I noticed in the first place: single and double digit megabyte backups labeled full.
76 of 262 rows claimed completed and full over an archive that could not have been one. Every active site had them, and on one site they went back to August 5.
A correction script judges each row two ways, in order. If the archive is still on disk, open it, because a zip holding only db.sql is a stub whatever the row claims. If the archive has been pruned, compare its recorded size against the largest full backup that same site has ever produced and call anything under 20% a stub. Per site, because these sites run from 190 MB to 5 GB and one global threshold would either miss 2.5 MB stubs on a small site or condemn its real backups. A corrected row becomes failed and keeps its original claim in metadata.corrected_from with the reason, so the whole pass reverts.
For a pruned archive, size is all you have, and it isn't proof. One truncated archive came in at 231 MB against 578 MB the day before, with 2,099 uploads against 3,571. It passes a size check and it is still not a backup. Only the on-disk arm catches those, and only while the file exists.
Check the archive, not the status
The check that matters reads each archive's own zip index and asks whether any site files are in there. It downloads nothing and takes no backup, so it is safe to run whenever:
python3 tools/check_archives.py # every site
python3 tools/check_archives.py <site> 8 # one site, last 8 backups
Exit 1 and a STUB line means an archive with no site files in it, no matter what the row says. If you use hosted backups rather than your own, the equivalent is downloading last night's archive and looking inside it for your uploads directory. A size that is orders of magnitude off is the giveaway.
As of September 29 every watched site produces a real archive. The last holdout had produced nothing since September 21: its host has no zip extension and kills the build worker about ten minutes in, so the archive is now built on the site across as many WP-Cron ticks as it takes, six ticks and 784 seconds for a 4.77 GB tar, coming down in 32 MB slices. One of the other sites turned out to have a last genuine backup three and a half weeks old.
Measured September 2026, across the WordPress sites I look after.