This week I got roasted on r/PHP for my deploy workflow - self-deleting token-gated PHP workers, uploaded through cPanel, no git, no containers. One of the critiques in the thread was my habit of keeping .bak files next to the files they back up. Fair hit. The punchline: I already knew. A reviewer had flagged backups-in-webroot weeks earlier, I shipped an .htaccess rule denying access to them, moved on. And this morning a spot check found 40 of 40 sampled backups returning 200 over plain HTTP - and the guard that was supposed to stop it had silently vanished from .htaccess entirely. This is the story of the 1,402 files, why the protection died without telling me, and the fix that actually holds: a vault outside the docroot, a manifest, and a weekly cron that verifies by request.
The droppings accumulate
My host gives me a cPanel file manager, not a shell, so every change to my 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 by four months of daily workers and you get 1,402 files named like arcade.html.bak-rackbay2.bak. Each one was a deliberate decision - keep the backup until I confirm 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: the .htaccess FilesMatch guard I shipped weeks ago was gone. Not weakened - absent. Some other edit or panel operation had rewritten .htaccess and my rule was no longer in it. I do not know when. That is the whole problem in one sentence: I could not have known when.
Verify by request, not by layout
The critique that changed my mind came from Mike Dabydeen, who reviewed my worker architecture: 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 the r/PHP thread 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 is to request it.
The vault
The fix has three parts, all shipped 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 cannot 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 it cannot claim authority over files it did not create.
- A weekly sweep. A CLI script, also in the vault and therefore unreachable by HTTP, running 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. 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.
The numbers, and the cache trap
Post-move verification: ten moved paths re-requested over HTTP, ten 404s. The manifest has every original path, the vault has every byte, the webroot has none.
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, I would have written down a false failure. Verify, then verify that the verification is the thing you think it is.
What changes in the template
The worker template 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," and the sweep cron exists even when I forget to. 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. The r/PHP thread caught what my own reviewer predicted - and both were cheaper than the breach that would have surfaced them.
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."
How do you handle deploy backups on managed hosting - sibling files, a vault directory, or do you just trust git? And has anyone else caught a security control that had silently stopped existing?