Five old WordPress sites, one morning, zero PHP left
I host a few dozen sites on one server. Among them were five that nobody had touched in years: the English and Dutch blogs of a dormant brand of mine (an e-learning platform for corporate trainers), a marketing site for online course creators, the site of an e-learning production company I no longer run, and a technical wiki I started around 2011. Four were WordPress, versions 4.7 to 5.4, on PHP 5.6 to 7.4. The fifth was a DokuWiki release named "Rincewind", from 2011, on PHP 5.6. All of it end of life several times over.
The content still had some value: old blog posts still get visitors, and the wiki pages still rank for a few things. But keeping a decade-old WordPress patched forever is not a hobby I want, and not patching it is a standing invitation. In late July another round of news about hacked WordPress sites came through, and that moved this from the someday list to the today list.
The fix was obvious: turn all five into static HTML and take PHP out of the path entirely. Nothing to patch if nothing executes. I asked Claude Code to do it. This note is about how that went.
The brief
Two hard requirements. Every existing URL must keep working; the blogs had years of inbound links and I did not want to break a single one. And no interactive element may survive: no forms, no search boxes, no comments, no trackers, nothing that expects a server behind it. Contact forms had already been replaced by plain email addresses on three of the sites; whatever else was left had to be flattened or removed. Comments could go entirely.
The rest I left to the agent, including where it ran (on the server, over SSH) and what tooling it used.
What it did
The approach it chose, and explained before doing it: mirror the sites first, do all the surgery on the mirror, and only then swap the mirror in. The live sites stayed untouched right up to cutover, so there was never a half-converted state visible to anyone, and a botched edit meant re-running a script rather than repairing a production site. It also pointed out that deactivating a WordPress plugin writes to the database, which on a live site is a change I have told it never to make without asking. Editing the crawled HTML sidesteps that completely.
Concretely:
- Archive. Database dumps for the four WordPress sites, tarballs of all five trees, checksummed and stored outside every document root. Nothing was deleted at any point; the old trees were moved aside, not removed.
- Inventory. A canonical list of every published URL, derived from WordPress itself and from the wiki's page store: 291 URLs, each verified live and returning 200 before anything changed. That list was both the crawl seed and, later, the regression baseline.
- Crawl.
wget --mirrorfor the WordPress sites. The wiki's URLs are all query strings (more on that below), so its 118 pages were fetched one by one. - Surgery. A Python script with BeautifulSoup that removed every form, the Disqus embeds, the "free checklist" call-to-action hardcoded into the blog theme, Contact Form 7 remnants, reCAPTCHA styles, Google Tag Manager and Analytics, dead feed links, the wiki's search/login/edit chrome, and so on. All of it through a parser; no regular expressions on markup.
- Cutover. Document roots swapped, virtual hosts patched, PHP-FPM pools for the five sites withdrawn, Apache reloaded. Config test first, because that box hosts sixty vhosts and a bad reload takes all of them down.
- Verification. See below, because this is the interesting part.
Preserving URLs: two different problems
The WordPress sites used pretty permalinks (/blog/some-post/), so mirroring each page as some-post/index.html keeps the URLs byte-identical. No redirects needed. My worry that URLs would "look like PHP files" was misplaced; the only .php URLs were login and XML-RPC endpoints that were meant to disappear.
The wiki was the real problem. It ran without URL rewriting, so all 118 pages lived at one filename with different query strings: /doku.php?id=moodle:grades, /doku.php?id=linux:apache_install, and so on. Apache resolves the file before it looks at the query, so putting static HTML into doku.php would serve the same page for every URL. Content replacement cannot work there.
The agent's solution was an internal rewrite in the vhost that maps the query onto a static file, with the visitor's URL unchanged and no redirect issued:
RewriteMap dwunesc int:unescape
RewriteCond %{QUERY_STRING} !(?:^|&)id=[^&]*(?:%2[fF]|%2[eE]%2[eE]|\.\.|/)
RewriteCond %{QUERY_STRING} (?:^|&)id=([A-Za-z0-9_:.%\-]+)
RewriteRule ^/?doku\.php$ /_static/pages/${dwunesc:%1}.html [L]
RewriteRule ^/?doku\.php$ /_static/pages/start.html [L]
Details I would not have thought of myself, or not immediately: the unescape map so that ?id=moodle%3Agrades and ?id=moodle:grades hit the same file; keeping the colon in the filename because it is legal on Linux and in URLs, so no translation table is needed; and a guard against path traversal, because without it ?id=%2F..%2F..%2Fetc%2Fpasswd would resolve outside the pages directory. It tested that guard with four payloads. The same mechanism maps the wiki's CSS, JS and media endpoints. It also chose internal rewrites over 301 redirects on purpose and said why: redirects would rewrite every bookmark and inbound link to a new URL scheme for no gain.
A smaller one: WordPress references assets as style.css?ver=4.9.29, and wget saves them with the query string in the filename, which Apache would never find. Renaming the files and leaving the HTML alone works because Apache ignores the query when serving a static file.
Taking PHP out of the path, four times
It removed the interpreter in four independent layers, any one of which would have been enough on its own: no .php files exist in the document roots; the five FPM pools are gone, so there is no socket to proxy to; the handler directive was removed from every vhost and replaced with an inert guard; and no .htaccess was carried over, so all routing sits in the vhost where it is visible. Belt, suspenders, and two more belts. I approve.
Verification, and where its own checks lied
The end result: all 291 baseline URLs re-requested, 286 return 200 and 5 return 404, those five being exactly the opt-in funnel pages we had agreed to drop. Zero forms, zero Disqus, zero tracking tags, zero references to wp-admin or xmlrpc in any attribute across 447 HTML files. Every page loaded in a browser and looked at, not just status-checked. Total footprint from 1.7 GB to 96 MB.
The part I found most instructive was the section of its report titled "Defects found in the process itself". Five of them, and four were bugs in the verification code, each producing a misleading pass at first:
- The step that renamed
index.html?paged=3files ran after the surgery, so those files never got cleaned. Caught by a re-scan showingxmlrpcreappearing on exactly the renamed files. - A case-sensitive check for
feedburnermatched the removed lowercase image URL and missed the surviving "RSS Feed/Feedburner" label. It reported zero. Making every assertion case-insensitive then surfaced two more leftovers. - An assertion harness split
grep -coutput on colons. Wiki filenames contain colons. Every assertion reported a uniform, meaningless "3". - A BeautifulSoup class matcher silently returned nothing, so a removal pass reported "0 removed" while 108 target elements were still present.
- An asset checker built URLs like
https://blog.trainertops.comfavicon.icoand reported 90 spurious failures.
Its own conclusion: "a check that passes proves the check ran and matched, not that the system is correct." I have that rule written down for it already, in the instructions every session gets. It is good to see it applied, and it is sobering how many ways a green check can be wrong.
Things it found that I did not ask about
Two of the sites had public test.php and info.php files that dumped a full phpinfo() page (server paths, configuration, environment) to anyone who asked. One blog had a Search-Replace-DB tool sitting in a zip in its document root. All gone now, and all embarrassing; those had been sitting there for years.
It also left me a list of things it deliberately did not touch because they were outside the brief: a handful of test pages from the WordPress era that were publicly live and so preserved, spam pages on the wiki, and the fact that RSS feeds are gone since they were dynamic endpoints. And it flagged that neighboring sites on the same server, out of scope, still ran WordPress on end-of-life PHP. Correct on all counts, and the right call to ask instead of act.
What I make of it
This is work I could have done myself. It would have taken me a couple of days of careful, boring effort, and I would have skipped half the verification and never written the report. The agent did it in one morning, wrote the report, and kept an audit trail I can roll back from with four commands per site.
What made it work, I think, is that the task was tightly specified in outcome (URLs preserved, no interactive elements, PHP gone) and completely open in method. It chose the mirror-first approach, the parser-based surgery, and the rewrite-map trick on its own. Where it needed a decision that changed the sites beyond the brief, it asked. Where a check could be wrong, it eventually noticed, but only because it re-scanned after each fix rather than trusting the first pass. I would not have wanted to run this without reading the report line by line afterward, and I did.
Tools: Claude Code, over SSH, with wget, Python and BeautifulSoup on the server. Total wall-clock time from brief to verified cutover: about three and a half hours, with a handful of decisions from me along the way (drop the comments, add two more sites to the list, keep the checklist PDFs reachable).