Nineteen OOM kills in nine days. Ten WordPress apps on a 32GB shared box. One of them a client site that took a CPU spike on 14 July during a paid campaign burst, and pushed the whole tenant into the wall.
This post is the diagnosis before the rebuild. What we actually found when we stopped guessing. Two layers of Cloudflare, one page cache plugin, one preloader being silently challenged, and a language subpath that was cold every time it mattered.
I'm writing it partly for anyone who runs multi-tenant WordPress on Cloudways or similar, and partly as a reminder for future-me. There's a checklist at the end. Steal it.
The client is anonymized throughout. Every number is real.
The stack
Traffic hits two Cloudflare layers before it reaches origin. Both are Cloudflare, but they're different zones on different accounts, and they own different things.
[ visitor ]
↓
[ Upstream Cloudflare zone (managed by a third party) ] ← DNS, SSL, HTML edge cache
↓
[ Cloudflare Enterprise add-on sold by Cloudways ] ← WAF, bot, rate limit, AI crawler block
↓
[ Cloudways origin: nginx + PHP-FPM ]
↓
[ WordPress + WPML + Elementor + FlyingPress ]
Two Cloudflares isn't a mistake. The domain has been on Cloudflare via an upstream party since before the site moved to Cloudways. When Cloudways later offered a Cloudflare Enterprise add-on for its security stack, we kept both. We manage the Cloudways side. We don't own the upstream zone, which shapes what we can and can't do without a request going out. The trap is that both layers can cache HTML, and both can serve security challenges. If nobody writes down which layer does what, they fight.
Our ownership split ended up like this:
| Layer | Owns |
|---|---|
| Upstream Cloudflare (third party) | DNS, SSL, HTML edge cache, purge lifecycle |
| Cloudways CF Enterprise add-on | WAF, bot management, rate limiting, AI crawler blocking, ScrapeShield, Browser Integrity Check |
| FlyingPress | Origin page cache, Cloudflare integration pointed at the upstream zone, purge rules |
| Cloudways "Edge Page Caching" (feature) | OFF. Turning it on would create a third HTML cache. |
That last row matters more than it looks. Cloudways sells "Edge Page Caching" as a nice-sounding thing you can toggle in their panel. Enable it and you have three parties trying to cache HTML for the same URL: the upstream CF zone, the Cloudways CF zone, and Cloudways' own edge. Purges from the plugin will hit one of them and leave the other two stale. You'll debug ghosts for a week.
Keep it off. FlyingPress owns the HTML cache. Full stop.
What broke on 14 July
The client ran a paid campaign. Traffic pushed harder than usual, mostly at one language subpath because the campaign was regionally targeted. CPU on the shared Cloudways box climbed and stayed climbed. PHP-FPM saturated. Response times went from 400ms to multi-second. Origin bandwidth was normal, which ruled out a DDoS or a bot flood.
I spent the first hour on the wrong hypotheses.
Not the problem:
- Bot traffic. The Cloudways CF WAF logs were clean. Nothing unusual.
- Disk I/O.
iostatshowed the array yawning. - MySQL. Slow query log was quiet.
- A plugin gone rogue.
wp plugin listmatched the last known-good state.
The real problem was compound, and every part of it made the next part worse:
- The targeted subpath's FlyingPress cache was cold. Not empty, but not warm at the pages the campaign was sending traffic to. Every ad click hit a URL that wasn't in cache.
- The upstream CF zone was not caching HTML. No Cache Rule, no Page Rule. So the edge added no protection. Every request went to origin.
- The FlyingPress preloader was silently failing. It crawls the sitemap and warms the origin cache. The Cloudways CF layer was serving it a Browser Integrity Check challenge. The preloader isn't a browser, so it never solved the challenge, so the pages never warmed.
- Third-party oEmbed calls per page. Several of the affected pages embed content from a large external social network. WordPress calls that oEmbed endpoint on render, synchronously, and blocks PHP until it answers. Under load, the provider was slow. PHP workers held.
- WPML + Elementor render cost. A cold page rendering through WPML routing and Elementor's builder is 100-200ms of PHP on a good day. Add oEmbed round-trips. Multiply by every worker being stuck.
Cold cache × silent preloader × blocking third-party call × heavy render stack × shared tenancy. Any one of those alone is a shrug. All five together is a CPU wall.
Why two Cloudflare layers, again
Because they own different concerns, and you don't want the security vendor also owning the cache purge lifecycle.
The upstream CF zone handles DNS. It came with an HTML cache nobody was using until the incident. FlyingPress has a native integration that manages that cache: it holds the Zone ID and a scoped API token, and it purges the right URLs when content updates. Since the zone isn't ours, both values had to be requested from the party that owns it.
The Cloudways Enterprise add-on gives us WAF rules, bot heuristics, rate limiting on abusive paths, and AI crawler blocking. That last one is worth its own post. The volume of scraper traffic from LLM crawlers has been non-trivial. We don't ask that layer to cache HTML. We ask it to reject the things we don't want to serve.
The mistake would be enabling HTML caching on both. Or worse, enabling Cloudways' third "Edge Page Cache" toggle and having three caches with three independent lifetimes.
The caching path that works
Once we understood the seams, the fix was mechanical. FlyingPress has a Cloudflare integration built in. Give it the right zone and the right token and it does the work.
Token scope. Don't over-scope this. If the zone belongs to a third party (as in our case), you're requesting the token from them. Ask for exactly this and nothing broader:
- Zone → Cache Purge → Purge
- Zone → Page Rules → Edit
- Zone → Zone Settings → Edit
- Zone → Cache Rules → Edit
That's it. No account-level permissions. No workers. No DNS edit. If someone in support tries to hand you a global API key, hand it back and ask for a scoped token.
Setup order.
- Paste Zone ID and API token into FlyingPress → CDN → Cloudflare.
- Click Test Connection. Get green.
- Enable Cloudflare Page Caching.
- Save.
- Purge all cache once. This is the only time you do a full purge on purpose. Everything after this is targeted.
- Verify.
Verify with curl, twice.
curl -sI https://client.example/some-page/ \
| grep -i cf-cache-status
First request should return cf-cache-status: MISS. Second request, within a minute, should return cf-cache-status: HIT. If the second request also returns MISS, the edge isn't caching and you need to check Cache Rules on the zone.
Edit-and-purge test. Open a test page in the WordPress editor. Change one word. Update. Then:
curl -sI https://client.example/test-page/ \
| grep -i cf-cache-status
Expect MISS (FlyingPress purged the URL on update), then HIT on the next call. If you get HIT immediately after an edit, the purge isn't wired. Check the token scope.
Editorial rules for the content team. These sound small. They keep the hit rate up.
- After editing a page: Clear current page, never Clear all cache.
- Don't purge multiple times in one editing session. One purge per finished edit.
- Don't cache logged-in users. FlyingPress has this setting. It should be off.
The "Clear all cache" button is a footgun. One editor with a habit of clicking it after every save can flatten a 700-page cache twice a day, and the preloader has to rebuild it every time, and you're back to hitting origin for everything until it's warm again.
What the fixes killed
Once the caching path was clean and the preloader was allowed through the Cloudways CF layer, the symptoms stopped stacking.
- The preloader now runs at sitemap scale. 775 pages cached, matching the site's public URL count.
- The targeted subpath is warm before campaigns launch.
- No more silent challenge on the preloader. We added a targeted allow rule for the preloader's origin IP on the Cloudways CF layer.
- CPU under normal traffic returned to baseline. No campaign burst has hit the same wall since.
The oEmbed problem didn't go away. It got buried under caching. Pages that used to call the third-party endpoint on every render now call it on the render that populates the cache, and once. That's fine at current traffic. If we scale 10x it's back on the list.
The ceiling nobody was tuning past
Here's the part that reframed the whole project.
Even with the caching path fixed, the box is still ten WordPress apps on 32GB of RAM. WordPress with Elementor and WPML has a fat baseline. 100 to 200MB of resident memory per PHP worker is normal. Each app's FPM pool holds workers, and each pool competes with nine other apps' pools for the same RAM.
Nineteen OOM kills in nine days. Roughly two per day. That's not a tuning problem. That's a tenancy problem.
I ran another week of measurements to confirm. PHP-FPM pm.max_children was already conservative on each app. opcache.memory_consumption was fine. There was no single-app memory leak. What was happening was traffic peaks on any one of the ten apps could push the shared box past its budget, and the kernel would kill whichever process it liked least.
At that point, tuning has diminishing returns. The next lever is one of:
- Upgrade the Cloudways plan. Bigger shared box, same ten apps. Buys headroom. Cheapest option. Reversible in an hour.
- Consolidate. Move some apps off. Reduces contention. Reversible, but touches other clients' sites, which means coordination cost.
- Move the biggest app to dedicated. OVH Advance-3 is on the shortlist. Reversible if we keep the Cloudways plan alive during transition for a week.
We're in the middle of that decision as I write this. Client hasn't picked yet.
Rules I set for myself before any migration
Written down so future-me doesn't get clever.
Reversibility test. Whatever I change, I need a path back to the previous state inside 24 hours. If I can't answer "how do I undo this by tomorrow", the plan isn't finished.
One variable at a time. If we move to dedicated, we don't also convert to Multisite in the same window. Two-variable moves are undebuggable when they break. Move first, convert later, or the other way around, never both.
Dual-run during migration. Old and new live in parallel for at least a week. DNS TTL down to 300 before the cutover so we can bounce back. No burning the old server until the new one has survived a real traffic day.
No infra work during editorial hours. The content team is in a different timezone from mine. If I break something they're editing, they can't reach me for hours. Migration windows are theirs, off-hours, negotiated.
What we're deferring on purpose
Every one of these has been raised at some point. Every one gets a "not yet" and a reason.
- Headless WordPress rewrite. Six-month project. The current fixes bought us the runway to plan it properly instead of doing it under an incident.
- WordPress Multisite conversion. High risk. Doesn't fix the tenancy problem, just moves it. Not in the migration window.
- Laravel or Filament rewrite. Premature. Editors know WordPress. Retraining the content team is a bigger cost than the hosting bill.
- Cancelling the Cloudways CF add-on. Still doing useful WAF and bot work. Don't remove security infrastructure during a period of instability.
Boring answer. Correct answer.
Checklist for future-me (and you, if it helps)
Save this. Run through it after every infra change, and before every campaign launch.
[ ] FlyingPress → Cloudflare integration shows Zone ID + scoped API token
[ ] Cloudways Edge Page Caching is OFF
[ ] curl cf-cache-status returns MISS then HIT on an anonymous URL
[ ] Edit a test page, save, verify purge (MISS on next curl, then HIT)
[ ] FlyingPress preloader completes; cached page count ≈ sitemap URL count
[ ] Preloader is not being challenged by the Cloudways CF layer
(check firewall events for the preloader's source IP)
[ ] Logged-in user caching is OFF in FlyingPress
[ ] Editorial team briefed: "Clear current page", never "Clear all"
[ ] 48h monitoring after any change:
- CPU peaks
- PHP-FPM saturation (max_children hit count)
- Origin request rate (should drop after caching is live)
- p95 response time
[ ] If OOM count > 5 in 7 days → stop tuning, escalate to hardware decision
[ ] Before any migration:
- Reversibility path in writing
- One variable at a time
- Dual-run for at least a week
- Migration window agreed with editorial team
The thing I keep learning
Caching plugins get treated as install-and-forget. Set up FlyingPress, tick the boxes, move on. The interesting problems are never inside the plugin. They're at the seams. Which layer caches. Which layer purges. Which layer challenges. Whether the preloader looks like a browser to the security layer. Whether the token has the right scope.
The 14 July spike wasn't a FlyingPress bug. It wasn't a Cloudflare bug either. It was a seams bug. Two Cloudflares, one plugin, and nobody's integration was pointed at the right zone. The fix was ten minutes of configuration once we knew what to configure. Getting to "what to configure" took two weeks.
Boring headers. Expensive lessons. Write down which layer owns what before you need to know.
Top comments (0)