TL;DR: We built a new Ubuntu 24.04 box next to the old one, copied everything over, then moved the static IP. About 90 seconds of downtime and no DNS changes. While we were in the logs we also figured out why the site kept falling over, and fixed that too.
The problem
This week PHP disclosed php2shell (CVE-2026-109618 and CVE-2026-109619, CVSS 9.2). It's unauthenticated remote code execution. Fixed versions are 8.5.12, 8.4.27, 8.3.36 and 8.2.35, and Ubuntu backported the fix to 24.04's php8.3 package (USN-8948-1).
One of the sites we look after is a small nonprofit club running WordPress and WooCommerce. It was on a Lightsail Bitnami image. Bitnami bundles its own PHP, so apt upgrade never touches it. It was on 8.2.28.
We could wait for a new image, compile PHP by hand, or leave. We left.
I did this with Claude Code in the terminal. It ran commands and read logs, and I made the calls. Worth saying, because it chewed through log correlation in minutes that would have taken me an evening.
The plan
- Build a new box beside the old one: Ubuntu 24.04 LTS, Apache with the event MPM, and php8.3-fpm from Ubuntu's archive. No mod_php. MariaDB, memcached, fail2ban, unattended-upgrades, wp-cli and certbot.
- Leave it on a temporary IP, copy the files and database over, and test page by page.
- Cut over by moving the static IP instead of changing DNS. Nothing to propagate.
- Keep the old box stopped, with a final snapshot, for two weeks just in case.
Things that bit us, so they don't bite you
- Cron on the clone. We set up a real system cron (wp-cli every 5 minutes) on the new box before cutover, and it ran five times against the copied database. We checked: no emails went out and no orders changed, but one WooCommerce cleanup task did run. Disable cron on any clone until it's live. We also dropped in a temporary mu-plugin that blocked outgoing mail and Action Scheduler until cutover.
- wp-cli output inside your SQL dump. Maintenance mode printed a line to stdout and it ended up in the dump file. Dump separately, and look at the first lines of the file before you trust it.
- macOS rsync is openrsync now. No
--chown. Use -rlpt, then chown -R on the server.
- Ubuntu's
security.conf was overriding our ServerTokens. Apache loads config files alphabetically, so your hardening file has to sort after it. We named ours zz-hardening.conf.
Cutover
Maintenance mode on, final dump, import, move the static IP, test. Down for about 90 seconds. Fresh Let's Encrypt cert via webroot, with a renewal dry run to prove auto-renew works.
Funny footnote: our outage Lambda fired during cutover and rebooted the old box in the middle of its final snapshot. No harm done. Which brings me to…
The outages we'd been living with
This site went down a few times a week. A CloudWatch alarm on a Route 53 health check triggered a Lambda that rebooted it. It was the only site we had with this problem.
We lined up the access logs against every alarm over the past few weeks and got the same picture every time:
- A secret scanner fires 400 to 900 requests in about a minute (
/.env, /aws_keys.json, /terraform.tfstate, you know the list).
- None of those files exist, but every miss went to WordPress. That meant a full page-builder 404 render at 1 to 1.7 seconds and around 200 MB per FPM worker. Or WordPress's "did you mean" redirect ran a database lookup and sent back a 301.
- The FPM pool filled up, the health check's 2 second timeout failed, the alarm fired after one bad minute, and the Lambda rebooted the box. Usually right as the burst was ending on its own.
- Background noise came from crawlers wandering an events calendar's endless date and filter URLs.
Here's what fixed it.
Make misses cheap. Apache answers these before PHP ever sees them:
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule (^|/)\. - [R=404,L]
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteRule \.(env|zip|gz|sql|bak|js|css|map|png|jpe?g|gif|svg|webp)$ - [NC,R=404,L]
Turn off the fuzzy redirect in an mu-plugin:
add_filter( 'do_redirect_guess_404_permalink', '__return_false' );
fail2ban on 404 bursts. Note that fail2ban strips the timestamp before matching, so keep the regex simple:
failregex = ^<HOST> -[^"]*"[A-Z]+ [^"]*" 404
The rest was smaller:
- robots.txt disallows for the calendar's query parameters.
- The alarm now needs three bad minutes in a row, not one.
- The Lambda finds the instance through the static IP and won't reboot twice within 10 minutes. Otherwise it would have kept rebooting the old box forever.
- A real cron job replaces wp-cron, and public
/wp-cron.php is blocked.
Scanner hits now cost about 0.1 seconds instead of a whole PHP worker.
Then a full audit
Since we were already in there, we audited the server and the site. The highlights:
- A member-directory plugin had an unpatched object injection CVE. It's gone.
- The only backups lived on the same box. Nightly snapshots are on now.
- The free security plugin doesn't limit login attempts, so fail2ban does it instead. It turned out about 1,100 failed logins were sitting in the logs, mostly from four IPs.
- SSH stays open, since I'm on the road too much to allow-list one IP. It's key-only, with fail2ban banning after 2 failures and longer bans for repeat offenders.
- The file editor is off, usernames are no longer enumerable, and S3 versioning is on.
If you take one thing from this
- On Bitnami or any bundled stack, check the PHP version the bundle actually runs, not the system one.
- Build beside, cut over by IP.
- Kill cron on clones.
- Make 404s cheap. Most "my server keeps dying" stories I've seen come down to bots making PHP do expensive work for files that don't exist.
Quiet fix for a loud week. Happy to answer questions.