This morning my portfolio had 1,402 backup files sitting in its public webroot. A spot check of forty of them over HTTP: forty of forty returned 200. Anyone on the internet could have downloaded my source, my deploy history, my worker code - one URL at a time, no auth, no warning. This is the story of how they got there, why the protection that was supposed to prevent it had silently vanished, and the fix that will actually hold.
1. The droppings accumulate
My host gives me a cPanel file manager, not a shell, so every change to this site ships as a token-gated PHP worker: it patches a file with needle guards, keeps a backup until I confirm the result, and self-reports. Multiply that workflow by four months of daily workers and you get what I found this morning: 1,402 files named like arcade.html.bak-rackbay2.bak. Each one was a deliberate decision - keep the backup until Bryan confirms the patch looks right. None of them was a decision to publish. They just... accumulated, in the webroot, because that is where the workers ran.
The worst part: I knew about this class of problem. A reviewer had flagged backups-in-webroot weeks ago, and I shipped an .htaccess FilesMatch rule denying access to .bak files and moved on. This morning's probe said the guard was gone. Not weakened - absent. Some other edit, plugin, or panel operation had rewritten .htaccess and my rule was simply no longer in it. I do not know when. That is the whole problem, in one sentence: I could not have known when.
2. Verify by request, not by layout
This is where the comment that changed my mind comes in. Mike Dabydeen reviewed my worker architecture last week and left a critique I keep returning to: verify by request rather than by layout, because on a managed host the layout is not yours to reason about. His concrete proposal: each worker writes every path it creates to a manifest outside the docroot, and the verify step requests each path over HTTP and fails on anything but a 403 or 404. His sharpest warning: a managed host serving PHP behind Nginx ignores .htaccess entirely, so the FilesMatch deny that works today does nothing after a plan change or a migration.
He was right, and this morning proved it twice over. First, the files were reachable: 40 of 40 sampled backups answered 200. Second, the guard was gone: the layout I thought I controlled had been rewritten underneath me. Configuration is a snapshot; requests are the ground truth. A file is only private if requesting it fails, and the only way to know that is to request it.
3. The vault
The fix has three parts, all shipped this morning in one worker:
- A vault outside the docroot. One level above public_html - a directory the web server can never serve because it is not in the document tree. All 1,402 files moved there in under a minute, mirroring their relative paths so nothing collides. Nothing was deleted; deleted backups can not be restored.
- A manifest. Every move recorded to a JSON file in the vault: original path, new path, timestamp, and the HTTP status codes found during the check. The manifest only knows about files my workers made - which is exactly the boundary Mike drew, because the manifest cannot claim authority over files it did not create.
- A weekly sweep. A CLI script, also in the vault and therefore unreachable by HTTP, that runs on cron: find every .bak, .old, .orig, and tilde-suffixed file in the docroot, request each one over HTTP, and log the ones that answer 200. Running it with a flag moves the live ones to the vault and says so, because an alert that just reports a count leaves you asking what to do at 6 a.m.
4. The numbers
The post-move verification is the satisfying part. Ten moved paths, re-requested over HTTP: ten 404s. The manifest has every original path. The vault has every byte. The webroot has none. And the sweep now guards the future: when an editor or a plugin drops an .old file in a theme folder next month, the Tuesday cron run will request it, find a 200, and move it out before it can matter.
One honest footnote that proves the point about verification: right after the move, my own spot check still showed 200s - the host's page cache was serving the backups from memory. The files were gone from disk and still answering requests. A few minutes later the cache expired and the same URLs returned 404. If my verify step had trusted one request instead of re-checking, I would have written down a false failure. Verify, then verify that the verification is the thing you think it is.
5. What changes in the template
The worker template I use for every deploy now carries the lesson structurally. Every path a worker creates or renames goes to the manifest. The verify step curls each one and fails the deploy on a 200. The backup policy is "outside the docroot, always" - the vault, not a sibling filename. And the sweep cron exists even when I forget to, because the threat is not the file I remember to protect; it is the file I forget I made.
Total cost: one directory, one JSON file, twenty lines of cron script, and a weekly line in the log. The alternative cost was 1,402 publicly readable files. Worth it.
Thanks to Mike Dabydeen for the review that predicted this failure mode before it happened - the manifest-and-request design in this post is his, and the fact that I found 40 of 40 files readable anyway is why "predicted by a reviewer" beats "fixed by an admin panel."