<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>WordPress | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/wordpress/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/wordpress/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sat, 24 Oct 2026 12:36:58 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>WordPress | John Nessime</title>
	<link>https://john-nessime.com/blog/wordpress/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>PHP2Shell Vulnerability: What Happened, How to Patch, and How to Prove It Worked</title>
		<link>https://john-nessime.com/blog/system-administration/php2shell-vulnerability-fix/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 24 Oct 2026 12:36:55 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[CloudLinux]]></category>
		<category><![CDATA[cPanel]]></category>
		<category><![CDATA[CustomBuild]]></category>
		<category><![CDATA[CVE Management]]></category>
		<category><![CDATA[CVE-2026-109618]]></category>
		<category><![CDATA[CVE-2026-109619]]></category>
		<category><![CDATA[DirectAdmin]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[mod_php]]></category>
		<category><![CDATA[Patch Management]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[PHP-FPM]]></category>
		<category><![CDATA[PHP2Shell]]></category>
		<category><![CDATA[Remote Code Execution]]></category>
		<category><![CDATA[Secrets Management]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[Vulnerability Management]]></category>
		<category><![CDATA[Web Application Firewall]]></category>
		<category><![CDATA[WordPress Security]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=1217</guid>

					<description><![CDATA[<p>PHP2Shell is a pair of critical flaws inside PHP itself, so updating WordPress or your framework fixes nothing. Here is what broke, who is exposed, how to patch every common setup, and how to prove the fix is actually running.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/php2shell-vulnerability-fix/">PHP2Shell Vulnerability: What Happened, How to Patch, and How to Prove It Worked</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">On the evening of 20 October 2026, php.net posted something it almost never posts: an advance warning that an emergency security release was coming the next day, for two critical bugs it would not yet describe. By the next morning the details had leaked, a proof-of-concept video was circulating, and the same message was landing in every hosting inbox: &#8220;We updated WordPress last week. We&#8217;re fine, right?&#8221;</p>



<p class="wp-block-paragraph">No. And that question is the entire problem with this one.</p>



<p class="wp-block-paragraph">This post is a reference for the <strong>PHP2Shell vulnerability</strong>: what actually broke inside PHP, which servers are exposed, how to patch each common setup, how to prove the fix is really running, and which quick fixes give you false comfort. Keep it open while you work through your servers.</p>



<h2 class="wp-block-heading">What happened: the PHP2Shell vulnerability in plain terms</h2>



<p class="wp-block-paragraph">PHP2Shell is the nickname researchers gave to two flaws in the PHP engine itself, <strong>CVE-2026-109618</strong> and <strong>CVE-2026-109619</strong>. PHP rates both 9.2 on CVSS v4.0, and both can let an unauthenticated attacker run code on a web server. The name is the researchers&#8217;, not the PHP project&#8217;s.</p>



<p class="wp-block-paragraph">The dates matter here, because they decide if you were exposed and for how long:</p>



<ul class="wp-block-list">
<li><strong>20 October 2026:</strong> PHP announced an emergency release for two critical issues. Details leaked publicly the same day.</li>



<li><strong>21 October 2026:</strong> fixed releases shipped: <strong>PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35</strong>, with a full advisory.</li>



<li><strong>23 October 2026:</strong> PHP published an informational advisory for a third, much narrower issue, CVE-2026-115560, with a one-line mitigation and no emergency release.</li>
</ul>



<p class="wp-block-paragraph">The flaws were reported by Wiz Research, Calif and OpenAI&#8217;s Codex Security team, who used AI models to turn long-standing weaknesses into working exploits. The affected code goes back to PHP 5.4, so effectively every PHP version from the last decade is in scope.</p>



<h3 class="wp-block-heading">The two bugs, without the jargon</h3>



<p class="wp-block-paragraph"><strong>CVE-2026-109618 (out-of-memory corruption).</strong> PHP enforces <code>memory_limit</code> by aborting a request the instant it crosses the limit. If that abort lands while PHP is still building internal request data, for example while parsing a multipart file upload, the data is left half-initialized. Request cleanup then treats it as valid and works with memory that can still hold attacker-controlled leftovers.</p>



<p class="wp-block-paragraph"><strong>CVE-2026-109619 (stale global state).</strong> Some state owned by one request can survive after its memory has been freed, for example after a fatal error. The next request handled by the same worker can use that stale state before normal setup resets it.</p>



<p class="wp-block-paragraph">Here is why this is worse than the usual plugin CVE. The bug lives in the runtime, not in your code. A site whose own code is flawless is still exposed, and updating WordPress, Laravel or Drupal changes nothing, because the PHP binary underneath comes from your OS packages, your host or your container image. The researchers also demonstrated reading memory from the PHP worker process, which can contain passwords, tokens and keys belonging to other people&#8217;s requests, without ever getting a shell.</p>



<h2 class="wp-block-heading">Who is exposed, and why mod_php is the worst case</h2>



<p class="wp-block-paragraph">PHP&#8217;s advisory covers every installation that serves web requests: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The risk is not equal across them, though.</p>



<p class="wp-block-paragraph"><strong>mod_php is the highest risk.</strong> Apache with mod_php runs request shutdown even when request startup already failed. That means simply running out of memory while PHP parses the request body is enough, and one unauthenticated request can trigger the attack before a single line of your application runs. PHP demonstrated this with default memory and input limits.</p>



<p class="wp-block-paragraph"><strong>PHP-FPM is harder to hit, not safe.</strong> FPM shuts the worker down when a request fails early in setup, which blocks the cleanest path. Public reporting says one of the two bugs was still shown to reach code execution under FPM, through a path that depends on something the application does. Treat &#8220;we run FPM&#8221; as &#8220;we have slightly more time,&#8221; nothing more.</p>



<p class="wp-block-paragraph">If you are not sure which mode you run, these two checks answer it on most Linux servers. The first lists Apache modules and shows a PHP module only if mod_php is loaded. The second lists running FPM services.</p>



<pre class="wp-block-code"><code>apachectl -M | grep -i php
systemctl list-units --type=service | grep -i fpm</code></pre>



<h2 class="wp-block-heading">How to fix PHP2Shell: it depends on where your PHP comes from</h2>



<p class="wp-block-paragraph">The fix is always the same in principle: run a patched PHP build. Getting there depends entirely on who builds your PHP. And there is a trap that runs in both directions. Linux distributions and long-term-support vendors received the patch early and backported it, so a <strong>patched</strong> package can carry an old-looking version like 8.3.6. Meanwhile, an <strong>unpatched</strong> process can keep running after the package updated. The version string answers neither question reliably.</p>



<h3 class="wp-block-heading">Debian and Ubuntu packages</h3>



<p class="wp-block-paragraph">Debian, Ubuntu and Ondřej Surý&#8217;s widely used repository (packages.sury.org, also published as ppa:ondrej/php) all shipped fixed packages, including builds for some end-of-life branches. Update normally, then confirm the CVE is in the package changelog instead of reading the version number. Replace 8.3 with your branch.</p>



<pre class="wp-block-code"><code>sudo apt update &amp;&amp; sudo apt upgrade
zgrep CVE-2026-109618 /usr/share/doc/php8.3-common/changelog.Debian.gz
sudo systemctl restart php8.3-fpm
sudo systemctl restart apache2</code></pre>



<p class="wp-block-paragraph">If <code>zgrep</code> prints a line, the fix is in the installed package. If it prints nothing, you are either on an older distro release that needs a paid extended-support channel, or on a repository that has not shipped the fix.</p>



<h3 class="wp-block-heading">RHEL, AlmaLinux and Rocky Linux</h3>



<p class="wp-block-paragraph">Same idea with RPM. Update, then search the package changelog for the CVE. If you use Remi&#8217;s parallel packages, the package name follows its own pattern (for example <code>php83-php-common</code>), so query that name instead.</p>



<pre class="wp-block-code"><code>sudo dnf upgrade 'php*'
rpm -q --changelog php-common | grep CVE-2026-109618
sudo systemctl restart php-fpm httpd</code></pre>



<p class="wp-block-paragraph">One warning from PHP&#8217;s own advisory: the volunteer-run rpms.remirepo.net repository was not expected to have updates on release day, and PHP suggested switching to distribution packages or building from source if you depend on it. Check before assuming the update landed.</p>



<h3 class="wp-block-heading">cPanel, CloudLinux and LiteSpeed</h3>



<p class="wp-block-paragraph">cPanel ships its own <code>ea-php</code> packages through EasyApache 4, and CloudLinux ships <code>alt-php</code>. CloudLinux backported the fix into supported and end-of-life branches, so most of its fixed builds keep version numbers below the php.net release. CloudLinux&#8217;s published procedure is to update both package families, then restart the services that run PHP so no worker keeps the old binary loaded.</p>



<pre class="wp-block-code"><code>yum clean all &amp;&amp; yum update 'alt-php*' 'ea-php*' -y
/scripts/restartsrv_httpd</code></pre>



<p class="wp-block-paragraph">If your domains run PHP-FPM through cPanel, restart the FPM service as well. On LiteSpeed, restart the web server so every <code>lsphp</code> process respawns from the new build. Public analysis says LiteSpeed&#8217;s LSAPI behaves more like FPM than mod_php, which buys time but does not remove the need to patch.</p>



<h3 class="wp-block-heading">DirectAdmin with CustomBuild</h3>



<p class="wp-block-paragraph">CustomBuild compiles PHP from source, so you get the fix when CustomBuild offers the patched release and you rebuild. Refresh the version list, confirm the fixed release appears for each PHP version you run, then build.</p>



<pre class="wp-block-code"><code>cd /usr/local/directadmin/custombuild
./build update
./build versions
./build update_versions</code></pre>



<p class="wp-block-paragraph">Read the <code>./build versions</code> output before running the last command. If a branch you use still shows an unpatched release, apply the mitigations below for that branch until it updates. Compiling takes a while on small servers, so plan for it rather than starting it at peak traffic.</p>



<h3 class="wp-block-heading">Docker and container images</h3>



<p class="wp-block-paragraph">A container never patches itself. If your image is built <code>FROM</code> an official <code>php</code> tag, rebuild with a fresh pull of the base image and redeploy. If your Dockerfile pins the base by digest, the digest has to change or nothing happens.</p>



<pre class="wp-block-code"><code>docker compose build --pull
docker compose up -d</code></pre>



<p class="wp-block-paragraph">Then run <code>php -v</code> inside the running container, not on the host, to confirm the version the app actually uses.</p>



<h3 class="wp-block-heading">Managed hosting versus unmanaged VPS</h3>



<p class="wp-block-paragraph">On managed hosting, the host owns the PHP runtime, and several large hosts announced they had patched their fleets. Your job there is to get written confirmation, not to assume. On an unmanaged VPS from Contabo, Interserver or any similar provider, nobody patches PHP but you. That is the trade you accepted for the lower price and full root access, and this week is when it gets collected.</p>



<h3 class="wp-block-heading">End-of-life PHP: 8.1 and older</h3>



<p class="wp-block-paragraph">The PHP project released no fixed builds below 8.2. Because of the severity, its security team published minimal source backports for PHP 7.4, 8.0 and 8.1 for people who build from source. Beyond that, your options are a distribution or vendor that backports (Ubuntu&#8217;s own PHP 8.1 on 22.04, the Surý repository, Freexian ELTS for older Debian, CloudLinux and TuxCare, HeroDevs), or upgrading. Note that PHP 8.2 itself reaches end of life on 31 December 2026, so if you are touching an 8.2 server now, plan the next upgrade at the same time.</p>



<h2 class="wp-block-heading">Prove the patch is actually running</h2>



<p class="wp-block-paragraph">This is the failure mode that will bite most people. The package manager says &#8220;upgraded,&#8221; the ticket gets closed, and the old PHP code keeps serving traffic because the processes that loaded it were never restarted. Linux happily keeps running a binary whose file has been replaced on disk.</p>



<ol class="wp-block-list">
<li><strong>Confirm the fix is in the package</strong> with the changelog check above, not the version string.</li>



<li><strong>Restart everything that runs PHP:</strong> every FPM pool, Apache (for mod_php), LiteSpeed, and any long-running PHP workers such as queue consumers managed by Supervisor or systemd.</li>



<li><strong>Check the restart time is after the upgrade.</strong> <code>systemctl status php8.3-fpm</code> shows when the service started. If that is earlier than your package install, it is still running old code.</li>



<li><strong>Ask the system what still needs a restart.</strong> On Debian and Ubuntu, <code>sudo needrestart -r l</code> lists services still using outdated files, without restarting anything.</li>



<li><strong>Check the right binary.</strong> <code>php -v</code> on the command line reports the CLI build, which can differ from the FPM build serving your site. On Debian and Ubuntu, <code>php-fpm8.3 -v</code> asks the FPM binary directly.</li>
</ol>



<h2 class="wp-block-heading">Mitigations if you cannot patch today, and what they don&#8217;t do</h2>



<p class="wp-block-paragraph">PHP is blunt about this: only an update is a complete fix. The settings below reduce exposure to the specific attack paths it has confirmed. They do not make a server safe, and some of them have real costs. Use them to buy hours, not weeks.</p>



<p class="wp-block-paragraph">Set them where applications cannot override them: <code>php_admin_value</code> or <code>php_admin_flag</code> in the FPM pool, or the Apache configuration for mod_php. Do not rely on <code>ini_set()</code> or an FPM <code>.user.ini</code> file, because those apply too late in the request. Here is the FPM pool version on a Debian-style path (adjust the file and branch to yours):</p>



<pre class="wp-block-code"><code>; /etc/php/8.3/fpm/pool.d/www.conf
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M
php_admin_value[upload_max_filesize] = 6M
php_admin_flag[session.upload_progress.enabled] = off</code></pre>



<p class="wp-block-paragraph">And the Apache equivalent for mod_php, inside the virtual host or a <code>Directory</code> block:</p>



<pre class="wp-block-code"><code>php_admin_value memory_limit -1
php_admin_value post_max_size 8M
php_admin_value upload_max_filesize 6M
php_admin_flag session.upload_progress.enabled off</code></pre>



<p class="wp-block-paragraph">Restart every worker afterwards. Now the reasoning behind each line, because each one protects a different path:</p>



<ul class="wp-block-list">
<li><strong><code>memory_limit = -1</code></strong> targets CVE-2026-109618. The confirmed exploit needs a predictable per-request limit to hit, so removing the limit removes the trigger. The cost: one request can now consume all available memory. A genuine allocation failure can also still reach the vulnerable code.</li>



<li><strong>Low <code>post_max_size</code></strong> shrinks the request bodies an attacker can use. PHP suggests 8M where uploads are not needed and 32M where they are, with <code>upload_max_filesize</code> kept below it.</li>



<li><strong><code>session.upload_progress.enabled = off</code></strong> targets CVE-2026-109619. It blocks the confirmed session paths but not other variants. It does nothing at all for CVE-2026-109618.</li>



<li><strong>On PHP 8.5 only, <code>fatal_error_backtraces = off</code></strong> blocks the confirmed fatal-backtrace path for CVE-2026-109619, and nothing else.</li>
</ul>



<p class="wp-block-paragraph">Because removing the memory limit opens a denial-of-service risk, PHP recommends a container or cgroup memory limit as the availability boundary, ideally one that kills only the affected worker. It specifically warns against <code>RLIMIT_AS</code> or <code>RLIMIT_DATA</code>, since those make allocations fail at the limit, which is exactly the condition the bug needs. Enforcing request-size, field-count and multipart-part limits at your reverse proxy helps too.</p>



<p class="wp-block-paragraph">A web application firewall is a reasonable stopgap, not a fix. Cloudflare deployed rules before the patch shipped and reported attackers changing payloads to get around them. If your site already sits behind Cloudflare, those rules help. They do not replace the update.</p>



<h3 class="wp-block-heading">The third issue: CVE-2026-115560</h3>



<p class="wp-block-paragraph">Two days after the release, PHP disclosed one more possible code-execution path. It needs an unusual setup: the application sets <code>session.serialize_handler</code> to <code>php_binary</code> or <code>php_serialize</code> in configuration, then switches it to <code>php</code> at runtime with <code>ini_set()</code> before calling <code>session_start()</code>, with session upload progress still enabled. The fixed releases above do not cover it. PHP&#8217;s advice is to keep <code>session.upload_progress.enabled=0</code> until the next regular bugfix release. If you already set it for CVE-2026-109619, leave it in place and you are done.</p>



<h2 class="wp-block-heading">After patching: assume someone tried</h2>



<p class="wp-block-paragraph">Amazon reported a honeypot hit against these flaws on 17 October, four days before disclosure, and exploitation attempts climbed fast once details were public. Most reports so far describe scanning and failed attempts rather than confirmed break-ins, but &#8220;nobody has reported it&#8221; is not evidence about your server. If you ran an unpatched, internet-facing PHP site during that window, do three things.</p>



<ul class="wp-block-list">
<li><strong>Rotate secrets a PHP worker could have held in memory:</strong> database passwords, API keys, payment provider keys, SMTP credentials and, for WordPress, the salts in <code>wp-config.php</code> (which logs every user out, so warn people first).</li>



<li><strong>Read your logs for the patterns researchers flagged:</strong> unusual multipart requests followed by memory-limit errors or bursts of HTTP 500 responses, oddly large <code>PHPSESSID</code> cookies in responses, and PHP workers restarting with no explanation.</li>



<li><strong>Look for anything new and executable</strong> in your web roots and upload directories, especially PHP files that no deploy put there.</li>
</ul>



<h2 class="wp-block-heading">Troubleshooting</h2>



<p class="wp-block-paragraph"><strong>&#8220;<code>php -v</code> still shows the old version.&#8221;</strong> Either it is a distro backport (check the changelog for the CVE), or you are looking at the CLI binary while a different build serves the site. Check the FPM binary directly and the container, not the host.</p>



<p class="wp-block-paragraph"><strong>&#8220;The site throws 500 errors after the update.&#8221;</strong> The fix changes how PHP handles running out of memory. Crossing <code>memory_limit</code> now raises a soft error at a safe point, and a second, higher threshold ends the process outright, without running shutdown handlers. A plugin or import job that used to squeak by can now fail differently. Check the error log, then review <code>memory_limit</code> and <code>post_max_size</code> against what the application genuinely needs.</p>



<p class="wp-block-paragraph"><strong>&#8220;Cleanup or logging code stopped running on big jobs.&#8221;</strong> Same cause. If anything important lives in a shutdown function, it will not run when the hard limit is hit. Fix the memory usage rather than raising limits blindly.</p>



<p class="wp-block-paragraph"><strong>&#8220;The package manager says there is nothing to update.&#8221;</strong> You are probably on a repository that has not shipped the fix, an end-of-life distro release, or an end-of-life PHP branch with no vendor backport. That is a migration problem, not a patching problem.</p>



<p class="wp-block-paragraph"><strong>&#8220;I set the mitigation but the old value still shows.&#8221;</strong> Look for overrides in other pool files, per-directory configuration or application code, and make sure you used the admin variants and restarted the workers.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Updating WordPress, plugins or the framework and calling it done. None of that touches the PHP runtime.</li>



<li>Trusting the version number in either direction, and declaring a backported package &#8220;vulnerable&#8221; or a stale process &#8220;patched.&#8221;</li>



<li>Upgrading the package and never restarting FPM, Apache or long-running workers.</li>



<li>Treating <code>session.upload_progress.enabled=0</code> as the fix. It was circulated as a workaround before the release and only covers part of one CVE.</li>



<li>Setting mitigations with <code>ini_set()</code>, <code>.htaccess</code> overrides that apps can change, or <code>.user.ini</code>, which take effect too late.</li>



<li>Leaving <code>memory_limit=-1</code> in place long after patching, with no cgroup boundary behind it.</li>



<li>Forgetting the boxes nobody owns: the staging server, the old client site on PHP 7.4, the forgotten container in a side project.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep an inventory of every PHP install, its source (distro, panel, container, source build) and its branch. This week shows why.</li>



<li>Verify fixes by CVE in the package changelog, and verify the running process by start time.</li>



<li>Patch mod_php servers first, then public FPM sites, then internal tools.</li>



<li>Once patched, restore a sane <code>memory_limit</code>, but keep session upload progress off until PHP&#8217;s next regular release covers CVE-2026-115560.</li>



<li>Move servers off mod_php to PHP-FPM where you can. It would not have saved you here, but it narrowed the easiest path.</li>



<li>Stay on a supported PHP branch, or pay for a vendor that backports. An unsupported branch turns every emergency into a migration.</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What is the PHP2Shell vulnerability?</h3>



<p class="wp-block-paragraph">It is the nickname for two memory-safety flaws in the PHP engine, CVE-2026-109618 and CVE-2026-109619, both rated 9.2 on CVSS v4.0. They can allow unauthenticated remote code execution on servers that run PHP for web requests.</p>



<h3 class="wp-block-heading">Which PHP versions fix PHP2Shell?</h3>



<p class="wp-block-paragraph">PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 from php.net. Distribution and vendor packages may carry the fix under different, older-looking version numbers, so check the package changelog for the CVE.</p>



<h3 class="wp-block-heading">Is my WordPress site affected?</h3>



<p class="wp-block-paragraph">If it runs on an unpatched PHP build, yes, regardless of how up to date WordPress and its plugins are. The flaw is in PHP itself, so the fix comes from your server, host or container image.</p>



<h3 class="wp-block-heading">Is disabling session.upload_progress enough?</h3>



<p class="wp-block-paragraph">No. It blocks the confirmed session paths for one of the two bugs and covers the narrow third issue, CVE-2026-115560. It does nothing for CVE-2026-109618. Only an update is a complete fix.</p>



<h3 class="wp-block-heading">I&#8217;m on PHP 7.4 or 8.1. What now?</h3>



<p class="wp-block-paragraph">php.net has no release for you, but PHP published source backports for 7.4, 8.0 and 8.1, and several vendors and distributions shipped backported packages. Use one of those immediately, then plan the upgrade to a supported branch.</p>



<h3 class="wp-block-heading">Do I need to restart anything after updating PHP?</h3>



<p class="wp-block-paragraph">Yes. Restart every PHP-FPM pool, Apache if you use mod_php, LiteSpeed, and long-running PHP workers. Until they restart, they keep running the vulnerable code already loaded in memory.</p>



<h3 class="wp-block-heading">Should I rotate passwords and keys?</h3>



<p class="wp-block-paragraph">If an internet-facing server ran an unpatched build after details went public, it is a sensible precaution. Researchers showed worker memory could be read remotely, and that memory can hold credentials from other requests.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">The PHP2Shell vulnerability lives below your application, so the only real fix is a patched PHP build that is actually running. Everything else in this post is either a way to get there or a way to buy time. If you remember one thing, make it this: verify the fix by CVE in the package, and verify the process by its restart time. The version string will mislead you in both directions.</p>



<h2 class="wp-block-heading">Need help patching PHP2Shell across your servers?</h2>



<p class="wp-block-paragraph">If you have more PHP servers than certainty, I can help you close this out properly:</p>



<ul class="wp-block-list">
<li>Inventory every PHP install across your VPS, cPanel, DirectAdmin and container hosts, with branch and package source.</li>



<li>Patch each one through the right channel and prove the fix by CVE and process restart time.</li>



<li>Apply and later roll back the interim mitigations safely, with a memory boundary in place.</li>



<li>Rebuild and redeploy PHP container images, including pinned-digest pipelines.</li>



<li>Review logs for the published indicators and plan secret rotation without breaking production.</li>



<li>Plan the move off end-of-life PHP branches and off mod_php.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>php -v</code>, your package changelog check, and how PHP runs on the server (FPM, mod_php or LiteSpeed), and I&#8217;ll tell you where you stand.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/php2shell-vulnerability-fix/">PHP2Shell Vulnerability: What Happened, How to Patch, and How to Prove It Worked</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Your WordPress 403 Isn&#8217;t the Rule You Think: Tuning ModSecurity False Positives</title>
		<link>https://john-nessime.com/blog/web-security/modsecurity-false-positives-wordpress/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 13 Oct 2026 06:06:40 +0000</pubDate>
				<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Anomaly Scoring]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[Coraza]]></category>
		<category><![CDATA[False Positives]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[ModSecurity]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OWASP CRS]]></category>
		<category><![CDATA[Rule Exclusions]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[Web Application Firewall]]></category>
		<category><![CDATA[WordPress Security]]></category>
		<category><![CDATA[wp-admin]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=434</guid>

					<description><![CDATA[<p>A 403 from ModSecurity almost never names the rule you need to change. Under anomaly scoring, detection rules only add to a score and a separate blocking rule returns the error. This guide covers reading the audit log backwards, choosing between the four exclusion mechanisms, the WordPress failure families that cause most false positives, and why raising the anomaly threshold quietly destroys your coverage.</p>
<p>The post <a href="https://john-nessime.com/blog/web-security/modsecurity-false-positives-wordpress/">Your WordPress 403 Isn&#8217;t the Rule You Think: Tuning ModSecurity False Positives</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket almost always reads the same way. Someone spent twenty minutes writing a post, hit Update, and got a bare <code>403 Forbidden</code>. Nothing in the WordPress debug log. Nothing in the PHP error log. The plugin they blame was not updated. The post they wrote yesterday saved fine.</p>



<p class="wp-block-paragraph">Then you open the ModSecurity audit log and find eleven rule matches on a single request, none of them obviously the culprit.</p>



<p class="wp-block-paragraph">Dealing with ModSecurity false positives on WordPress is not hard once you understand how the OWASP Core Rule Set decides to block. It is very hard if you go looking for &#8220;the rule that blocked me,&#8221; because under the default configuration that rule does not exist. This post covers reading the log, choosing the right exclusion mechanism, the WordPress failure families that generate most of the noise, and the tuning knobs that quietly gut your coverage.</p>



<h2 class="wp-block-heading">The invisible part: no single rule blocked the request</h2>



<p class="wp-block-paragraph">The Core Rule Set runs in anomaly scoring mode by default. Individual detection rules do not deny anything. They match, they log, and they add to a running score with <code>setvar</code>. At the end of the request phase, a separate blocking evaluation rule compares the accumulated score against a threshold and returns the 403.</p>



<p class="wp-block-paragraph">Scores come from rule severity. A CRITICAL match adds 5, ERROR adds 4, WARNING adds 3, NOTICE adds 2. The inbound threshold ships at 5 and the outbound threshold at 4. So one critical match is enough on its own, and so are two notices plus a warning.</p>



<p class="wp-block-paragraph">That is the failure mode nobody warns you about. The log line that produced your 403 lives in <code>REQUEST-949-BLOCKING-EVALUATION.conf</code> and it says the inbound anomaly score was exceeded. It does not name what matched. Take that ID and disable it and you have not fixed a false positive, you have turned off blocking for the whole site while leaving every detection rule running and logging. The site will look fixed. It is not protected.</p>



<p class="wp-block-paragraph">First rule of tuning, then: the blocking rule is a symptom. Read backwards from it.</p>



<h2 class="wp-block-heading">Read the log before you touch a rule</h2>



<p class="wp-block-paragraph">Every alert carries a <code>unique_id</code>. That value ties the error log line to the full audit log record for the same transaction, and it is the only reliable way to see every rule that fired on one request instead of a scattering of lines from different visitors.</p>



<pre class="wp-block-code"><code># Paths vary by build. Check SecAuditLog in modsecurity.conf for yours.
tail -n 200 /var/log/httpd/error_log | grep -i modsecurity

# Then pull the whole transaction using the unique_id from that output
grep -F 'Zm9vYmFyCg' /var/log/httpd/modsec_audit.log</code></pre>



<p class="wp-block-paragraph">From each match, four fields matter and the rest is noise:</p>



<ul class="wp-block-list">
<li><strong>The rule ID</strong>, as <code>[id "941320"]</code>. This is what you exclude.</li>



<li><strong>The matched variable</strong>, at the end of the pattern-match message as something like <code>at ARGS:content</code>. This is what you exclude it <em>from</em>.</li>



<li><strong>The URI</strong>, which tells you how narrowly you can scope the exclusion.</li>



<li><strong>The paranoia level tag</strong>, as <code>[tag "paranoia-level/2"]</code>. If every alert is tagged level 2 or higher, the real fix may be a paranoia level change rather than an exclusion.</li>
</ul>



<p class="wp-block-paragraph">One warning most guides skip: audit logs of false positives contain the payload that triggered them. If a user&#8217;s password happens to look like a shell command, it lands in your log in plaintext. Treat these files as sensitive, rotate them, and redact before pasting anything into a public bug report.</p>



<h2 class="wp-block-heading">Pick the right exclusion mechanism</h2>



<p class="wp-block-paragraph">ModSecurity gives you four ways to write an exclusion, differing on two axes: applied once at startup or evaluated per request, and removing the whole rule or just one variable from it.</p>



<ul class="wp-block-list">
<li><code>SecRuleRemoveById</code> and <code>SecRuleRemoveByTag</code> delete a rule or a whole tagged category at configure time. Blunt, cheap, global.</li>



<li><code>SecRuleUpdateTargetById</code> and <code>SecRuleUpdateTargetByTag</code> strip one variable from a rule at configure time, leaving the rule live for everything else.</li>



<li><code>ctl:ruleRemoveById</code> and <code>ctl:ruleRemoveByTag</code> do the same removal, but only for transactions matching a condition you write.</li>



<li><code>ctl:ruleRemoveTargetById</code> and <code>ctl:ruleRemoveTargetByTag</code> strip one variable, conditionally. This is the surgical option and the one to reach for first.</li>
</ul>



<p class="wp-block-paragraph">Runtime exclusions cost a little, since each is an extra rule evaluated on every request. That overhead is small next to the regex work the rule set already does, and the precision is worth it.</p>



<h3 class="wp-block-heading">Placement decides whether an exclusion works at all</h3>



<p class="wp-block-paragraph">This is where most people lose an afternoon. The two types go in opposite places, for mechanical reasons rather than arbitrary ones.</p>



<ul class="wp-block-list">
<li><strong>Configure-time exclusions go after the CRS include.</strong> They remove rules, and a rule has to exist before you can remove it.</li>



<li><strong>Runtime exclusions go before the CRS include.</strong> They change how rules behave for this transaction, and changing a rule after it has already run does nothing.</li>
</ul>



<p class="wp-block-paragraph">The Core Rule Set ships two files for exactly this, both with an <code>.example</code> suffix you drop before they load. <code>REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf</code> holds runtime exclusions, and <code>RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf</code> holds configure-time ones. Use them. Local files that survive a rule set update are the entire point.</p>



<h2 class="wp-block-heading">Five failure families behind most ModSecurity false positives</h2>



<p class="wp-block-paragraph">Almost everything you will hit on WordPress falls into one of these. Recognizing the family tells you the shape of the fix before you read a single log line.</p>



<h3 class="wp-block-heading">1. Post content that legitimately contains markup</h3>



<p class="wp-block-paragraph">WordPress lets editors put HTML in posts. It maintains its own allowed-tag list, sanitizes server side, and that mechanism has been reviewed hard over the years. The Core Rule Set knows none of this. It sees an <code>&lt;h1&gt;</code> or an inline <code>style</code> attribute in a POST parameter and matches an XSS rule.</p>



<p class="wp-block-paragraph">This produces the &#8220;it saved yesterday&#8221; ticket, because it is content-dependent. Plain paragraphs pass. A pasted table with attributes does not. The fix is a target exclusion scoped to the save endpoint and the specific parameter, never a global rule removal:</p>



<pre class="wp-block-code"><code># Runtime exclusion. Goes in REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
# Replace 941320 and the parameter with what your audit log actually shows.
SecRule REQUEST_URI "@beginsWith /wp-admin/post.php" 
    "id:1000,
    phase:1,
    pass,
    t:none,
    nolog,
    ctl:ruleRemoveTargetById=941320;ARGS:content"</code></pre>



<p class="wp-block-paragraph">Pick your own IDs from a range CRS will never use. Reusing an existing ID is a startup error rather than a silent one, which is the good kind of mistake.</p>



<h3 class="wp-block-heading">2. REST and admin-ajax calls that fail with no visible error</h3>



<p class="wp-block-paragraph">This family is nastier, because there is no error page. The browser hits <code>/wp-json/</code> or <code>/wp-admin/admin-ajax.php</code>, gets a 403, and the JavaScript either swallows it or shows a generic &#8220;updating failed&#8221; toast. Autosave stops. An editor panel stays empty. A form plugin silently drops submissions. Users report this as flakiness rather than a security block, so it sits unfixed for months. When someone says the editor is &#8220;just slow sometimes,&#8221; open the browser network tab and look for 403s before going anywhere near PHP.</p>



<p class="wp-block-paragraph">Scoping differs here. REST routes are structured, so use <code>@rx</code> against <code>REQUEST_FILENAME</code> to hit a route family rather than one path. For <code>admin-ajax.php</code>, the useful discriminator is usually the <code>action</code> parameter, since that one endpoint multiplexes dozens of unrelated plugin handlers.</p>



<h3 class="wp-block-heading">3. Media uploads that break at a size boundary</h3>



<p class="wp-block-paragraph">Multipart uploads have two independent failure paths that look identical from the browser. The first is a genuine rule match on the multipart structure, or on binary content containing a byte sequence some rule dislikes. That shows up with a rule ID and you tune it normally.</p>



<p class="wp-block-paragraph">The second is an engine limit, not a rule. <code>SecRequestBodyLimit</code> caps the total body ModSecurity will buffer, <code>SecRequestBodyNoFilesLimit</code> caps the non-file portion, and <code>SecRequestBodyLimitAction</code> decides whether an oversized body is rejected outright or inspected partially. If uploads fail above a consistent size and no CRS rule ID appears anywhere in the log, stop tuning rules. That is a limit, and the fix is in engine config.</p>



<p class="wp-block-paragraph">Raising them costs memory per concurrent request, and partial inspection leaves part of the body unscanned. Decide which trade you are making rather than bumping the number until the ticket closes.</p>



<h3 class="wp-block-heading">4. Cookies with dynamic names</h3>



<p class="wp-block-paragraph">WordPress authentication cookies carry a hash in the name, so a literal exclusion is impossible. Configure-time directives accept a regular expression in the target if you wrap it in forward slashes:</p>



<pre class="wp-block-code"><code># Configure-time exclusion. Goes in RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf
SecRuleUpdateTargetById 942440 "!REQUEST_COOKIES:/^wordpress_logged_in_.*/"</code></pre>



<p class="wp-block-paragraph">The catch is significant: the <code>ctl</code> action supports no regular expressions at all. If you need a pattern match on a variable name, you are forced into the configure-time form, which applies site-wide rather than to one path. That single constraint drives more design decisions than people expect.</p>



<h3 class="wp-block-heading">5. Chained rules that refuse to be tuned</h3>



<p class="wp-block-paragraph">Some rules are written as a chain of conditions. Target exclusions only apply to the first rule in the chain, so if the variable you want to strip is referenced further down, nothing you write has any effect and you will burn an hour convincing yourself the config file is not loading.</p>



<p class="wp-block-paragraph">When a target exclusion visibly does nothing on a rule you can watch matching, check whether it is chained. If it is, your only option is removing the whole rule under a narrow condition. Accept the wider blast radius, or change the request instead.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Install the WordPress plugin before writing anything custom</h2>



<p class="wp-block-paragraph">The Core Rule Set maintains a WordPress rule exclusions package. In CRS 3 it was built in and switched on with a variable in <code>crs-setup.conf</code>. In CRS 4 it moved out into a separate plugin published in the CRS plugin registry, and it is not installed by default.</p>



<p class="wp-block-paragraph">That catches people upgrading. Exclusions that used to work vanish, WordPress starts throwing 403s again, and the setup variable they remember is gone from the config file. The reasoning behind the split is sound: fewer rules loaded means less code that can carry a bug, and one of the old exclusion packages did carry a bypass.</p>



<p class="wp-block-paragraph">Plugins load through wildcard includes in a specific order. Get the order wrong and the plugin silently does nothing:</p>



<pre class="wp-block-code"><code>Include /path/to/crs/crs-setup.conf

Include /path/to/crs/plugins/*-config.conf
Include /path/to/crs/plugins/*-before.conf

Include /path/to/crs/rules/*.conf

Include /path/to/crs/plugins/*-after.conf</code></pre>



<p class="wp-block-paragraph">Two limits before you lean on it. The plugin covers vanilla WordPress only, so false positives from third-party plugins, page builders and form plugins are still yours to tune. And the comment field is deliberately left out of the exclusions, because comment forms have a long history as a real attack surface. If your comment form throws false positives, that is a decision the maintainers made on purpose, and overriding it is a genuine risk trade rather than a gap in their package.</p>



<p class="wp-block-paragraph">On a server hosting more than one application, scope the plugin to the WordPress vhost using <code>WebAppID</code> or a Host header condition. WordPress exclusions applied to your unrelated API are a hole you did not mean to open.</p>



<h2 class="wp-block-heading">The knobs that look like a fix and are not</h2>



<p class="wp-block-paragraph">When exclusions get tedious, two shortcuts start looking attractive. Both cost more than they appear to.</p>



<p class="wp-block-paragraph"><strong>Raising the anomaly threshold.</strong> Going from 5 to 20 makes most false positives vanish, and it also means any attack category that scores low can never trigger a block. Detections that only ever produce a single critical match become unblockable. The CRS documentation is direct here: keep the threshold at the default and change the paranoia level instead. Raising it is a temporary tuning aid, not a configuration.</p>



<p class="wp-block-paragraph"><strong>Dropping the paranoia level.</strong> Legitimate, but often applied backwards. Level 1 is designed to produce few false positives. Levels 2 and up add progressively more aggressive rules and progressively more tuning work. If your alerts are all tagged level 2, dropping to level 1 is the correct fix and it is honest about what you gave up.</p>



<p class="wp-block-paragraph">The better tool for moving up is the split between blocking and detection paranoia levels. Set <code>tx.blocking_paranoia_level</code> to what you actually enforce and <code>tx.detection_paranoia_level</code> higher. Rules from the higher level execute and log, but only rules up to the blocking level count toward the score. You get to see what level 2 would have caught, in production, on real traffic, without anyone getting blocked. Then you tune, then you promote. The performance cost lands as soon as you raise the detection level, though, since those rules run either way.</p>



<p class="wp-block-paragraph">Two smaller settings help here. <code>tx.sampling_percentage</code> sends only a fraction of requests into the rule set, a gentler introduction for a busy site than blocking everything on day one. And <code>tx.reporting_level</code> can log requests that <em>would</em> have been blocked at a higher paranoia level, which is exactly the signal you want while climbing.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<p class="wp-block-paragraph"><strong>The exclusion has no effect.</strong> Check placement first. A runtime exclusion loaded after the rules, or a configure-time exclusion loaded before them, fails silently. Then check for a chained rule. Then confirm the file is being included at all by breaking it deliberately with a syntax error and seeing whether the server refuses to start.</p>



<p class="wp-block-paragraph"><strong>It works on one site and not another.</strong> Different vhosts load different config. On control panels, per-domain overrides usually sit in a separate file from the global rule set.</p>



<p class="wp-block-paragraph"><strong>It worked and then stopped.</strong> Something updated the rule set. If you edited CRS rule files directly, your edits are gone, which is the whole argument against editing them.</p>



<p class="wp-block-paragraph"><strong>Nothing appears in the log at all.</strong> Confirm the rule engine is on, and that auditing is enabled with enough log parts to include the request body. An engine in detection only mode logs without blocking, which is confusing if you did not know you were in it.</p>



<p class="wp-block-paragraph"><strong>You are on shared hosting and cannot edit any of this.</strong> Some hosts expose a per-domain exclusion box, some let you disable rule IDs, some give you nothing. That is a real ceiling. Providers that hand you root, such as InterServer or Contabo, let you own the rule set and its update cadence, which on a site with genuine tuning needs beats the panel convenience you trade away.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Disabling the blocking evaluation rule because it showed up in the log. It reports the score, it detects nothing, and removing it disables blocking site-wide.</li>



<li>Editing CRS rule files directly. Every update reverts you, and you now maintain a fork by hand.</li>



<li>Excluding by message rather than by ID. Messages carry dynamic data and special characters, so the match is fragile.</li>



<li>Removing an entire attack tag because two rules misfired. <code>SecRuleRemoveByTag attack-sqli</code> is defensible when nothing behind the WAF touches SQL. On WordPress it is not.</li>



<li>Excluding <code>/wp-admin/</code> wholesale, or stacking multiple rule set vendors. Admin is where a compromised session does the most damage, and overlapping rule sets make attribution guesswork.</li>



<li>Leaving known false positives in place. Real attacks get lost in the noise, and the logs accumulate user data you never intended to collect.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Start in detection only. Collect real traffic, tune what you find, then enable blocking. Enforcing first is how you learn about broken checkout at two in the morning.</li>



<li>Install the WordPress exclusions plugin before writing custom rules. Do not hand-write what upstream already maintains.</li>



<li>Write the narrowest exclusion that resolves the alert: one rule, one variable, one path. Comment it with the rule ID, the endpoint, and why you judged it safe. Six months later that comment is the only thing between you and a blind revert.</li>



<li>Keep exclusion files in version control and deploy them with the rest of your config. Test rule set updates on staging, since new signatures land regularly.</li>



<li>Ship audit logs somewhere queryable. Grepping a growing text file stops scaling fast, and a platform like Grafana Cloud turns &#8220;which rule fires most&#8221; into a query instead of an afternoon.</li>



<li>If an edge WAF such as Cloudflare sits in front of the origin, decide deliberately which layer owns which rule. Two WAFs scanning the same body doubles your false positive surface for no extra coverage.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">How do I find which ModSecurity rule blocked my WordPress request?</h3>



<p class="wp-block-paragraph">Take the <code>unique_id</code> from the 403 entry in the error log and grep the audit log for it. That gives you every rule that fired on the transaction. The blocking evaluation rule is what returned the 403, but the detection rules listed alongside it are what you tune.</p>



<h3 class="wp-block-heading">Should I disable ModSecurity for WordPress instead of tuning it?</h3>



<p class="wp-block-paragraph">It is a defensible choice if the alternative is blocking mode with unresolved false positives, since a WAF everyone has learned to route around is worse than none. But detection only mode gives you the logs with none of the breakage, and that is almost always the better middle ground while you work through the tuning.</p>



<h3 class="wp-block-heading">Is the WordPress rule exclusions plugin enough on its own?</h3>



<p class="wp-block-paragraph">For a stock install on a standard theme, usually. It covers vanilla WordPress and both block-based and classic themes. It does not cover third-party plugins, page builders, or theme frameworks, and those generate most of the false positives on a real site.</p>



<h3 class="wp-block-heading">Why do false positives come back after a rule set update?</h3>



<p class="wp-block-paragraph">Either you edited rule files directly and the update overwrote them, or the update shipped new signatures your existing exclusions do not cover. The first is avoidable by keeping every change in the exclusion files. The second is the cost of running a maintained rule set, and it is why updates belong on staging first.</p>



<h3 class="wp-block-heading">Does ModSecurity slow WordPress down?</h3>



<p class="wp-block-paragraph">It adds per-request work, mostly regular expression evaluation against request data, and the cost scales with paranoia level and body size. The settings that matter are the paranoia level, the request body limits, and whether response body inspection is on. Measure on your own traffic rather than trusting someone else&#8217;s benchmark.</p>



<h3 class="wp-block-heading">Is ModSecurity still maintained?</h3>



<p class="wp-block-paragraph">The engine moved to the OWASP Foundation after Trustwave stepped back from commercial support, and it still receives fixes, though feature work is slower than it once was. The Core Rule Set is a separate project on its own cadence and is actively developed. Coraza is a compatible engine running the same rule language, worth evaluating for new deployments.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">The thing to hold on to about ModSecurity false positives on WordPress is that the rule which returned your 403 is almost never the rule you need to change. Anomaly scoring separates detection from blocking on purpose, and reading the log backwards from the blocking decision to the detection rules that fed it is the whole skill.</p>



<p class="wp-block-paragraph">Everything else follows. Install the maintained WordPress exclusions instead of reinventing them. Write the narrowest exclusion you can, in the file that survives updates. Move the paranoia level, not the threshold.</p>



<h2 class="wp-block-heading">Need help tuning your WAF?</h2>



<p class="wp-block-paragraph">Most of the WAF work I take on is untangling exactly this: a rule set that is technically installed and practically ignored, because nobody had time to work through the false positives. I can help with:</p>



<ul class="wp-block-list">
<li>Triaging your audit logs and mapping every recurring false positive to the rule, variable and endpoint that produced it</li>



<li>Writing scoped exclusion sets for WooCommerce, form plugins, page builders and custom REST routes that upstream packages do not cover</li>



<li>Moving a site off direct rule file edits and onto exclusion files that survive rule set updates</li>



<li>Planning a CRS 3 to CRS 4 migration, including the exclusion package to plugin transition and the paranoia level variable renames</li>



<li>Running a staged rollout from detection only to blocking, with the reporting and sampling settings that make it safe</li>



<li>Shipping ModSecurity audit logs into a queryable log platform so rule tuning becomes a dashboard instead of a grep</li>
</ul>



<p class="wp-block-paragraph">If you have a blocked request you cannot explain, send me the audit log entry and your exclusion file and I will tell you what is firing and why.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/web-security/modsecurity-false-positives-wordpress/">Your WordPress 403 Isn&#8217;t the Rule You Think: Tuning ModSecurity False Positives</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</title>
		<link>https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 10 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Residency]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[Presigned URLs]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=382</guid>

					<description><![CDATA[<p>Free egress is not free serving. R2 still bills every read, S3 does not bill origin transfer to CloudFront, and the variable that decides both bills is cache hit ratio. An evenhanded comparison of Cloudflare R2 vs Amazon S3 for media offloading, with the four levers that move the number and a decision procedure you can run against your own traffic.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The disk-full alert was the easy part. A media library outgrows the box it lives on, someone moves it to object storage over a weekend, the alert stops firing, everyone moves on. Two weeks later the complaint is different: images are slow for visitors on the other side of the planet, and there is a line on the bill nobody can explain item by item.</p>



<p class="wp-block-paragraph">Moving the files is the solved part. Which bucket they land in, and what sits in front of that bucket, decides whether you spend almost nothing or spend more than the server you were trying to shrink.</p>



<p class="wp-block-paragraph">Cloudflare R2 vs Amazon S3 usually gets reduced to one sentence: R2 does not charge for data transfer out, S3 does, therefore R2. That is wrong in both directions, and understanding why is most of the value here. This post covers the failure mode that shows up after the migration rather than during it, an honest profile of each service, the levers that actually move the number, and a procedure you can run against your own traffic in an afternoon.</p>



<h2 class="wp-block-heading">Free egress is not free serving</h2>



<p class="wp-block-paragraph">R2 charges nothing to move bytes out, on any storage class. It still charges for the read. Every <code>GetObject</code> and every <code>HeadObject</code> is a Class B operation, and a media library made of thousands of small files racks those up fast. Cloudflare&#8217;s own asset hosting example in the R2 pricing docs makes the point: storage falls inside the free tier, writes fall inside the free tier, and the whole bill is read operations.</p>



<p class="wp-block-paragraph">So the meter is not gigabytes. The meter is requests that reach the bucket, which means the variable deciding your bill on either platform is cache hit ratio.</p>



<p class="wp-block-paragraph">This is where offloading setups go wrong without anyone noticing. The plugin rewrites image URLs to point at the bucket, images render, pages look fine, and every request for every thumbnail travels all the way to the origin and gets billed. Nothing breaks. Nothing alerts. The number just grows with traffic.</p>



<p class="wp-block-paragraph">There is a sharper version on the R2 side. A new bucket gives you a public URL on an <code>r2.dev</code> subdomain, which is convenient and tempting. Cloudflare documents it as a testing endpoint with a variable rate limit. Push production traffic through it and requests get throttled with <code>429 Too Many Requests</code>, throughput can be throttled too, and you get no cache, no WAF, no bot management. Those only exist once the bucket sits behind a custom domain you control. That is the invisible failure: not a broken image, a working setup billed on every request and rate limited under load.</p>



<h2 class="wp-block-heading">Cloudflare R2: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where R2 wins</h3>



<ul class="wp-block-list">
<li><strong>Egress really is zero.</strong> Not zero within a partner network, not zero up to a ratio of stored data. Zero, on both storage classes, through the S3 API and through Workers.</li>

<li><strong>A custom domain puts it behind the CDN.</strong> One managed CNAME and reads start being absorbed at the edge instead of hitting the bucket. No second product to buy and wire up.</li>

<li><strong>Deletes are free operations</strong>, along with aborting a multipart upload. Cleaning up a messy library costs nothing.</li>

<li><strong>Unauthorized requests are not billed.</strong> A caller without permission gets a 401 and you are not charged, which matters once someone starts hammering your bucket path.</li>

<li><strong>Migration tooling is free to use.</strong> Super Slurper copies a bucket across in bulk, Sippy migrates lazily on first miss. You pay only for the operations they perform against R2.</li>
</ul>



<h3 class="wp-block-heading">Where R2 hurts</h3>



<ul class="wp-block-list">
<li><strong>S3 compatible is not S3 identical.</strong> Cloudflare publishes a table of which operations are implemented and which are not. Real tooling has broken on the gaps: when AWS shipped SDKs that enabled CRC32 checksums by default, R2 rejected the header until the mismatch was resolved.</li>

<li><strong>There is no cold archive tier.</strong> Infrequent Access is as cold as it gets, and it carries a minimum storage duration, a retrieval fee, and doubled operation rates. For an active library that is the wrong tier, not a saving.</li>

<li><strong>Data lives in one primary location.</strong> Location hints are best effort rather than a region guarantee, and are honored only the first time a bucket with that name is created. Read performance away from that location comes from the edge cache, not from replicas.</li>

<li><strong>Plugin support is thinner.</strong> WP Offload Media, the long-standing WordPress option, officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage. R2 users generally land on Media Cloud, Advanced Media Offloader, Next3 Offload, or the S3-Uploads route driven from WP-CLI.</li>
</ul>



<h2 class="wp-block-heading">Amazon S3: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where S3 wins</h3>



<ul class="wp-block-list">
<li><strong>Origin transfer to CloudFront is not billed.</strong> This quietly demolishes the usual comparison. AWS waives data transfer from an AWS origin to CloudFront, so a properly built S3 media stack never pays the direct-to-internet rate on cached traffic.</li>

<li><strong>The CloudFront free allowance is permanent</strong>, covering a monthly volume of data transfer out and requests rather than expiring after twelve months. Small sites can sit inside it indefinitely.</li>

<li><strong>Event driven derivatives are mature.</strong> Upload fires a Lambda that generates thumbnails, strips metadata, converts formats, writes the result back. Hard to beat if your media pipeline does real work.</li>

<li><strong>A full storage ladder with real lifecycle transitions</strong>, down to deep archive. If a large chunk of your library is genuinely cold, S3 has somewhere cheap to park it.</li>

<li><strong>Governance depth.</strong> Object Lock, versioning, replication, IAM condition keys, CloudTrail data events. When an auditor asks for a control, S3 usually has a named feature for it.</li>
</ul>



<h3 class="wp-block-heading">Where S3 hurts</h3>



<ul class="wp-block-list">
<li><strong>The default configuration is the expensive one.</strong> Point image URLs at the bucket endpoint and every byte leaves at the internet rate with nothing cached in front of it. The cheap path has to be built deliberately.</li>

<li><strong>Four meters instead of two:</strong> storage, bucket requests, CDN requests, CDN data transfer, plus per-feature charges. Forecasting takes real effort.</li>

<li><strong>Geography changes the rate.</strong> Delivery costs more per gigabyte to some regions than others, so an audience shift moves your bill without you changing anything.</li>

<li><strong>Leaving costs money.</strong> Copying a large library out is billed transfer, once, in a lump. Usually small against ongoing savings, but it is a real number somebody has to approve.</li>
</ul>



<h2 class="wp-block-heading">How to decide between Cloudflare R2 vs Amazon S3</h2>



<p class="wp-block-paragraph">Four levers move the outcome. Everything else is noise.</p>



<h3 class="wp-block-heading">Cache hit ratio</h3>



<p class="wp-block-paragraph">The dominant term on both platforms, and the one people skip. At a high hit ratio the origin barely gets touched and both bills collapse toward the storage line. At a low hit ratio, R2 bills read operations and S3 bills origin GETs, and both climb with traffic. Media should be trivially cacheable because the files never change. If yours is not caching, that is a configuration problem worth fixing before you pick a vendor, because fixing it changes the answer.</p>



<h3 class="wp-block-heading">Object count, not library size</h3>



<p class="wp-block-paragraph">WordPress does not store one file per upload. It stores the original plus every registered image size, and themes and page builders happily register more. Ten thousand uploads can easily be sixty thousand objects, each one a write on migration and a read on a cache miss. Count them before you model anything:</p>



<pre class="wp-block-code"><code>cd /path/to/wordpress

# Objects you are about to create in the bucket
find wp-content/uploads -type f | wc -l

# Bytes
du -sh wp-content/uploads

# How much of that count is derivative sizes rather than originals
find wp-content/uploads -type f -regextype posix-extended 
  -regex '.*-[0-9]+x[0-9]+.(jpe?g|png|webp|avif)$' | wc -l</code></pre>



<p class="wp-block-paragraph">That last number is usually the surprise. If most of your object count is thumbnails, trimming unused registered sizes before migrating is the cheapest optimization available, and it shrinks writes, reads, and storage at once.</p>



<h3 class="wp-block-heading">How cold the library actually is</h3>



<p class="wp-block-paragraph">Most libraries are a long tail. A small set of recent files takes nearly all the traffic while the rest sits untouched for years. If that tail is large, S3 has somewhere genuinely cheap to put it and R2 does not. Be careful with R2&#8217;s Infrequent Access class here: it lowers the storage rate but raises both operation rates, adds a retrieval fee, and enforces a minimum duration whether you keep the object or not. It fits write-once, read-almost-never data, not a library anything still links to.</p>



<h3 class="wp-block-heading">What the rest of your stack already is</h3>



<p class="wp-block-paragraph">If your application already runs in AWS and generates derivatives with Lambda on upload, moving to R2 means rebuilding that pipeline against Workers or R2 event notifications. That is real work you do not get paid for. If your site is a VPS from somewhere like InterServer or Contabo with Cloudflare already in front of it, R2 is nearly free work: the DNS is there, the cache is there, and the bucket slots in behind a subdomain you already control.</p>



<h2 class="wp-block-heading">Setup details that change the answer</h2>



<p class="wp-block-paragraph">A few configuration choices matter more than the vendor choice. Get these wrong and the cheaper platform produces the bigger bill.</p>



<h3 class="wp-block-heading">Serve from a custom domain, always</h3>



<p class="wp-block-paragraph">On R2 this is the difference between a cached, protected asset host and a throttled test endpoint. Attach one from the dashboard or from Wrangler:</p>



<pre class="wp-block-code"><code>npx wrangler r2 bucket domain add my-media-bucket 
  --domain=cdn.example.com 
  --zone-id=&lt;YOUR_ZONE_ID&gt;

npx wrangler r2 bucket domain list my-media-bucket</code></pre>



<p class="wp-block-paragraph">Then disable public access on the <code>r2.dev</code> subdomain, or you have left a second uncached, unprotected door into the same objects.</p>



<h3 class="wp-block-heading">Verify the cache instead of assuming it</h3>



<p class="wp-block-paragraph">Assumptions about caching are where the money leaks. Ask the edge directly: request the same object twice and read the headers.</p>



<pre class="wp-block-code"><code># Cloudflare in front of R2
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg 
  | grep -iE 'cf-cache-status|cache-control|age'

# CloudFront in front of S3
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg 
  | grep -iE 'x-cache|cache-control|age'</code></pre>



<p class="wp-block-paragraph">First request, expect a miss. Second, expect a hit. If the second still reports a miss, or reports a status meaning the response was never eligible for caching, every image view is costing you an origin read. Fix that before you compare anything.</p>



<h3 class="wp-block-heading">Long cache lifetimes and versioned filenames</h3>



<p class="wp-block-paragraph">Uploaded media is immutable in practice. Nobody edits the bytes of <code>photo-1024x768.jpg</code>, they upload a new file. So cache lifetimes should be long and the object name should change when the content does. If you find yourself purging image caches regularly, the real problem is that your filenames are not versioned.</p>



<p class="wp-block-paragraph">On the Cloudflare side, Smart Tiered Cache is worth enabling for R2 origins. It routes edge misses through an upper tier data center close to your bucket instead of letting every edge location fetch independently, which cuts the number of requests reaching R2 at all.</p>



<h3 class="wp-block-heading">Clean up failed uploads</h3>



<p class="wp-block-paragraph">Large uploads use multipart. Interrupted ones leave orphaned parts that occupy billed storage and do not show up in a normal object listing. Both platforms support lifecycle rules for this, and on R2 you can manage them from Wrangler:</p>



<pre class="wp-block-code"><code>npx wrangler r2 bucket lifecycle list my-media-bucket

npx wrangler r2 bucket lifecycle add my-media-bucket 
  --name=expire-temp 
  --prefix=tmp/ 
  --expire-days=30</code></pre>



<p class="wp-block-paragraph">Configure an abort rule for incomplete multipart uploads on day one, whichever platform you land on. It is the most common source of storage you pay for and cannot see.</p>



<h2 class="wp-block-heading">A decision procedure you can run this afternoon</h2>



<ol class="wp-block-list">
<li><strong>Measure your current cache hit ratio</strong> for image paths, from analytics or a sampled read of access logs. Everything downstream depends on this number.</li>

<li><strong>Count objects, not gigabytes.</strong> Run the <code>find</code> commands above and separate originals from derivative sizes.</li>

<li><strong>Estimate monthly origin reads</strong> as total image requests multiplied by the miss rate. That, not your bandwidth, is what you feed into either pricing calculator.</li>

<li><strong>Treat writes as a one-off migration spike</strong> plus a modest steady rate. Migration is usually the largest write event the bucket ever sees.</li>

<li><strong>Price both against current published rates</strong> on the same day, using Cloudflare&#8217;s R2 calculator and the AWS pricing calculator. Rates move. Do not trust a number copied from a blog post, including this one.</li>

<li><strong>Test with a prefix before committing.</strong> Offload one year of uploads, point the site at it, watch cache status headers and the operations dashboard for a week. A week of real traffic beats any spreadsheet.</li>
</ol>



<p class="wp-block-paragraph">If it comes out close, take R2. Not because it is cheaper in that scenario, but because it has fewer meters to reason about, and a simpler operational model is worth something on the day something breaks.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<h3 class="wp-block-heading">&#8220;S3 charges egress, R2 doesn&#8217;t, so R2 always wins&#8221;</h3>



<p class="wp-block-paragraph">Only true against the worst possible S3 setup. Transfer from an S3 bucket to CloudFront is not billed, so a properly built S3 media stack pays CDN delivery rates on cache misses, not the direct-from-bucket rate. Add CloudFront&#8217;s permanent free allowance and a small site pays nothing on either platform. The gap opens at scale and on origin-heavy traffic, not on the first terabyte.</p>



<h3 class="wp-block-heading">&#8220;R2 is a CDN&#8221;</h3>



<p class="wp-block-paragraph">R2 is object storage that can sit behind a CDN you already have. The caching, the WAF, the bot rules all come from the Cloudflare zone, and none of it applies to the development URL. If the bucket is not behind a custom domain on a zone you control, you have storage without delivery.</p>



<h3 class="wp-block-heading">&#8220;S3 compatible means drop-in&#8221;</h3>



<p class="wp-block-paragraph">It means most tools work with a changed endpoint and a region value of <code>auto</code>. It does not mean every operation, header, and checksum behaves identically. Budget an afternoon for fighting an SDK default, and test uploads, multipart uploads, deletes, and signed URLs against the real bucket before cutting over.</p>



<h3 class="wp-block-heading">&#8220;We&#8217;ll save money moving cold files to Infrequent Access&#8221;</h3>



<p class="wp-block-paragraph">Sometimes. The lower storage rate arrives with higher operation rates, a retrieval fee, and a minimum duration. If files are cold enough to justify that, they are probably cold enough to belong in a real archive tier, which R2 does not have. Model it against your read pattern rather than assuming a cheaper per-gigabyte number is a cheaper bill.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is Cloudflare R2 cheaper than S3 for a WordPress media library?</h3>



<p class="wp-block-paragraph">Usually, once traffic is meaningful and the library is served publicly. For a small site behind a well configured CDN, both can land near zero. R2&#8217;s advantage grows with egress volume and with the number of requests that miss cache, which is why measuring hit ratio comes first.</p>



<h3 class="wp-block-heading">Can I use the same WordPress plugin for both?</h3>



<p class="wp-block-paragraph">Several plugins speak the S3 API and support both, including Media Cloud, Advanced Media Offloader, and Next3 Offload. WP Offload Media officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage, so check current provider support before assuming a swap is free. Whichever you pick, confirm it rewrites existing URLs and not only new uploads.</p>



<h3 class="wp-block-heading">How do I migrate an existing S3 bucket to R2 without downtime?</h3>



<p class="wp-block-paragraph">Cloudflare offers two paths. Super Slurper copies everything in bulk. Sippy migrates incrementally, pulling an object from the source the first time it is requested and serving from R2 afterward. Both are free to use, you pay for the operations they perform against R2, and your source bucket may charge you for the reads.</p>



<h3 class="wp-block-heading">Do I still need a CDN in front of R2?</h3>



<p class="wp-block-paragraph">You need a custom domain on a Cloudflare zone, which is what puts the CDN in front of it. That is not optional for production. The development URL is rate limited by design and gets no cache, WAF, or bot management.</p>



<h3 class="wp-block-heading">Can I keep S3 and just put Cloudflare in front of it?</h3>



<p class="wp-block-paragraph">You can, and it does cut origin reads. Be aware that cache misses then pull from S3 across the public internet, billed as ordinary S3 egress, unlike an S3 origin sitting behind CloudFront. Reasonable as an interim step, rarely the cheapest end state.</p>



<h3 class="wp-block-heading">What about Backblaze B2, Wasabi, or DigitalOcean Spaces?</h3>



<p class="wp-block-paragraph">All viable and worth pricing if you are already modelling. B2 competes on raw storage, Wasabi sells predictability, Spaces is convenient when your droplets are already there. The analysis transfers directly: find the meters, find your cache hit ratio, multiply.</p>



<h3 class="wp-block-heading">How do I monitor the bill after migrating?</h3>



<p class="wp-block-paragraph">Watch operations and cache hit ratio, not storage. Storage grows slowly and predictably. Operations track traffic and configuration mistakes, which is where the unpleasant surprises live. A Grafana Cloud dashboard or a scheduled pull from the provider&#8217;s usage API is enough; the point is that somebody looks weekly rather than at invoice time.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">If you take one thing from this Cloudflare R2 vs Amazon S3 comparison, take this: the vendor choice is second order. What decides your media offloading bill is how many requests reach the bucket, and that is a function of your cache configuration, your object count, and whether you put a real domain in front of the storage.</p>



<p class="wp-block-paragraph">Fix the cache first. Count the objects. Then price both, on the same day, against your own numbers. Most of the time R2 comes out ahead for public media, S3 comes out ahead when the library is entangled with AWS services or needs a genuine archive tier, and the difference is smaller than the internet suggests.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help moving a media library without breaking it?</h2>



<p class="wp-block-paragraph">Media offloading looks like a plugin install and turns into a URL rewriting, caching, and permissions problem. I work with teams on the parts that are easy to get subtly wrong:</p>



<ul class="wp-block-list">
<li>Modelling R2 against S3 using your real object counts, request volumes, and cache hit ratio instead of a generic calculator</li>

<li>Planning and running the migration: bulk or incremental copy, URL rewriting, and a rollback path if the cutover misbehaves</li>

<li>Custom domain, cache rule, and header configuration so origin reads collapse instead of tracking your traffic</li>

<li>Bucket permissions, CORS, signed URL flows for private downloads, and lifecycle rules for orphaned multipart uploads</li>

<li>Trimming registered image sizes and derivative sprawl before migration so you stop paying to store thumbnails nothing links to</li>

<li>Dashboards for operations, cache hit ratio, and storage growth so the bill stops being a monthly surprise</li>
</ul>



<p class="wp-block-paragraph">Send me a <code>curl -I</code> of one of your image URLs and a rough object count, and I can usually tell you quickly whether you have a pricing problem or a caching problem.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Bulk WordPress Migration: Moving 20 Sites to a New VPS in One Weekend</title>
		<link>https://john-nessime.com/blog/technical-guides/bulk-wordpress-migration-vps/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 29 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Hosting & Infrastructure]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Cutover Planning]]></category>
		<category><![CDATA[Decommissioning]]></category>
		<category><![CDATA[DirectAdmin]]></category>
		<category><![CDATA[DNS Migration]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[Incremental Sync]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[MX Records]]></category>
		<category><![CDATA[Rollback Plan]]></category>
		<category><![CDATA[rsync]]></category>
		<category><![CDATA[search-replace]]></category>
		<category><![CDATA[Server Provisioning]]></category>
		<category><![CDATA[SPF]]></category>
		<category><![CDATA[TTL]]></category>
		<category><![CDATA[VPS]]></category>
		<category><![CDATA[Website Migration]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WordPress Migration]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<category><![CDATA[WP-Cron]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=301</guid>

					<description><![CDATA[<p>Moving one WordPress site is easy. Moving twenty in a weekend is a pipeline, and the failures that cost you are the quiet ones: writes landing on the old server, cron firing on two boxes, certificates blocked by ACME rate limits. Here is the fleet migration process I use, from manifest to per-site rollback.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/bulk-wordpress-migration-vps/">Bulk WordPress Migration: Moving 20 Sites to a New VPS in One Weekend</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The message arrived on Monday morning. Three quote requests submitted over the weekend, confirmed by the people who submitted them, and none of them in the CRM. The site was up. The contact form worked when I tested it. Nothing in the error log.</p>



<p class="wp-block-paragraph">Those submissions went to the old server. It was still answering for a slice of the internet twenty hours after the DNS change, and by then I had already stopped syncing it.</p>



<p class="wp-block-paragraph">That is the failure that separates a bulk WordPress migration from moving a single site. When you move one site you watch it. When you move twenty in a weekend, you are running a pipeline, and the things that go wrong are the ones nobody is watching: writes landing on a server you have mentally decommissioned, cron firing on two boxes at once, a certificate that never issued because a DNS record was still pointing at the old IP when the challenge ran.</p>



<p class="wp-block-paragraph">This is the process I use for fleet moves onto a single VPS: the manifest that makes the weekend repeatable, the two-pass sync, the database step people rush, the certificate limits that bite specifically at fleet scale, and a cutover order that keeps rollback cheap. It assumes SSH on both boxes and some comfort on the command line.</p>



<h2 class="wp-block-heading">The failure that actually costs you: writes landing on the old server</h2>



<p class="wp-block-paragraph">Every migration guide tells you to lower your TTL. Almost none of them tell you what happens in the gap afterwards, and the gap is where the money goes.</p>



<p class="wp-block-paragraph">Here is the mechanism. You change the A record. Most resolvers pick up the new IP inside the TTL window. Some do not: corporate resolvers that clamp minimum TTLs, ISP resolvers that ignore short values, browsers holding a connection open, a mail server that resolved the host an hour ago. For those clients, the old server is still the site. It still has a working database. It still accepts comments, form entries, orders, user registrations and password resets.</p>



<p class="wp-block-paragraph">Nothing errors. Nothing logs. The write succeeds on a database you are about to throw away.</p>



<p class="wp-block-paragraph">There are only two honest answers to this, and picking one per site is the first real decision of the weekend.</p>



<ul class="wp-block-list">
<li><strong>Accept the loss.</strong> Fine for brochure sites, documentation, most blogs. A missed comment is not a business problem.</li>
<li><strong>Make the old server read-only the moment DNS changes.</strong> Mandatory for anything with a checkout, a booking flow, a members area or a lead form that feeds a sales process.</li>
</ul>



<p class="wp-block-paragraph">Read-only in practice means maintenance mode on the old copy, a static holding page on the old vhost, or pointing the old install&#8217;s <code>wp-config.php</code> at a database user with SELECT only. Any of those turns silent data loss into a visible error the visitor can react to. Someone who sees &#8220;temporarily unavailable&#8221; comes back. Someone whose order vanished does not.</p>



<p class="wp-block-paragraph">Do not skip this because the old server &#8220;will be off soon&#8221;. The window between DNS change and old server shutdown is the longest, least supervised part of the whole weekend.</p>



<h2 class="wp-block-heading">Build the fleet manifest before you touch a server</h2>



<p class="wp-block-paragraph">Twenty sites is the point where memory stops working. You will not remember which one runs an old PHP branch, which one has a hardcoded absolute path in a theme, which one has MX records pointing somewhere else entirely.</p>



<p class="wp-block-paragraph">So the first artifact is a table. One row per site, filled in before anything moves.</p>



<ul class="wp-block-list">
<li>Document root and database name</li>
<li>Current <code>siteurl</code> and <code>home</code> values, exactly as stored</li>
<li>PHP version the site currently runs under</li>
<li>Disk size of the document root and dump size of the database</li>
<li>Where DNS is authoritative, and who holds the login</li>
<li>Whether mail for that domain is on this server or elsewhere</li>
<li>Does it take writes from the public: comments, forms, checkout, registration</li>
<li>Cache and object cache in use</li>
<li>Cutover batch number and rollback owner</li>
</ul>



<p class="wp-block-paragraph">Most of that you can pull off the old server rather than typing it. This walks a DirectAdmin-style layout and prints a tab-separated line per WordPress install:</p>



<pre class="wp-block-code"><code>for d in /home/*/domains/*/public_html; do
  [ -f "$d/wp-config.php" ] || continue
  printf '%st%st%sn' 
    "$d" 
    "$(wp --path="$d" option get siteurl --skip-plugins --skip-themes 2&gt;/dev/null)" 
    "$(du -sh "$d" | cut -f1)"
done</code></pre>



<p class="wp-block-paragraph">The <code>--skip-plugins --skip-themes</code> flags matter here. Without them WP-CLI boots the full site, and one broken plugin on one install will stop your inventory halfway through with a fatal error.</p>



<p class="wp-block-paragraph">The DNS column is the one people fill in last and regret. Check where each zone actually lives before the weekend, not during it. A domain whose nameservers point at Cloudflare while you are editing records in the old host&#8217;s DNS panel will not move no matter how many times you refresh.</p>



<h2 class="wp-block-heading">Rebuild the stack, don&#8217;t clone the disk</h2>



<p class="wp-block-paragraph">There is a tempting shortcut where you image the old server and restore it onto the new one. It usually works and it is usually the wrong call, because you have just carried across every accumulated fix, every orphaned config, and every reason the old box got slow enough to need replacing.</p>



<p class="wp-block-paragraph">Build the destination clean. Whatever the provider is, whether that is Contabo, InterServer, Hetzner or a machine you already have, the sequence is the same: base OS, web server, PHP with the version and extensions the manifest says you need, database server, control panel if you use one, firewall, then a hardening pass.</p>



<p class="wp-block-paragraph">Two things to get right before any files land.</p>



<p class="wp-block-paragraph"><strong>PHP versions.</strong> If three sites need an older branch and seventeen are happy on current, install both and make the per-site assignment explicit in the manifest. Discovering the mismatch after cutover means a white screen on a live domain instead of a note in a spreadsheet. Check the extension list too, not just the version number, because <code>imagick</code>, <code>soap</code> and <code>intl</code> are the usual absentees.</p>



<p class="wp-block-paragraph"><strong>Access during the weekend.</strong> If you lock SSH and the control panel behind an IP allowlist, decide beforehand what your source address will be. A rotating home IP is how people lock themselves out of their own migration at two in the morning. A dedicated-IP option from a VPN provider such as NordVPN or Surfshark gives you one stable address to allowlist; a second admin path on a non-standard port works too. Deciding at the moment you are locked out does not.</p>



<h2 class="wp-block-heading">Two-pass rsync beats one big copy</h2>



<p class="wp-block-paragraph">The mistake at fleet scale is treating the file copy as a cutover-day task. Copy everything on cutover day and your maintenance window is however long twenty document roots take, which for a media-heavy fleet is hours you do not have.</p>



<p class="wp-block-paragraph">Split it. Do a warm sync days in advance while both sites are live, then a delta sync at cutover that moves only what changed.</p>



<p class="wp-block-paragraph">Warm sync, run from the old server, no deletes:</p>



<pre class="wp-block-code"><code>rsync -aHAX --numeric-ids --info=progress2 
  -e "ssh -p 22" 
  /home/user/domains/example.com/public_html/ 
  root@NEW_SERVER_IP:/home/user/domains/example.com/public_html/</code></pre>



<p class="wp-block-paragraph">What those flags buy you: <code>-a</code> preserves permissions, times, symlinks and ownership; <code>-H</code> keeps hard links, which some backup plugins and dedup schemes rely on; <code>-A</code> and <code>-X</code> carry ACLs and extended attributes; <code>--numeric-ids</code> stops rsync remapping ownership through name lookups when the UIDs differ between boxes, which is exactly what happens when the new server creates users in a different order.</p>



<p class="wp-block-paragraph">Delta sync at cutover, same command with one addition:</p>



<pre class="wp-block-code"><code>rsync -aHAX --numeric-ids --delete --info=progress2 
  -e "ssh -p 22" 
  /home/user/domains/example.com/public_html/ 
  root@NEW_SERVER_IP:/home/user/domains/example.com/public_html/</code></pre>



<p class="wp-block-paragraph"><code>--delete</code> removes files on the destination that no longer exist on the source. It is what makes the second pass an accurate mirror rather than a merge of two states. It is also the flag that will destroy your afternoon if you get the paths wrong, so check the trailing slashes: a source path ending in a slash copies the contents of the directory, one without it copies the directory itself, one level deeper than you meant.</p>



<p class="wp-block-paragraph">Run the delta pass once with <code>--dry-run</code> before running it for real. And on SELinux systems, relabel the tree afterwards: files arriving over rsync carry contexts the web server may not accept, and that produces a 403 which has nothing to do with permission bits.</p>



<h2 class="wp-block-heading">Databases last, and never with sed</h2>



<p class="wp-block-paragraph">Files can be stale for a few days. Databases cannot. Dump each database as late as you reasonably can, immediately before that site&#8217;s cutover, not on Friday for a Sunday move.</p>



<pre class="wp-block-code"><code># on the old server, per site
wp --path=/home/user/domains/example.com/public_html db export 
  /root/migration/example.com.sql --skip-plugins --skip-themes</code></pre>



<p class="wp-block-paragraph">Import on the new box, then handle URLs. If the domain is not changing, you may have nothing to replace. If the site is moving from HTTP to HTTPS, or from a staging hostname, or from a temporary preview domain you used for testing, you do.</p>



<p class="wp-block-paragraph">The rule that matters: do not run a find-and-replace over the SQL file with <code>sed</code>. WordPress stores widget settings, theme options, page builder layouts and plugin config as PHP serialized arrays, and every serialized string carries a byte-length prefix. Change the string without recalculating the prefix and the array fails to unserialize. The site does not error loudly. It loses its widgets, or its theme options revert to defaults, or the page builder renders an empty page.</p>



<p class="wp-block-paragraph">WP-CLI unserializes, replaces and reserializes, which is why it is the only tool I use for this.</p>



<pre class="wp-block-code"><code>wp search-replace 'http://example.com' 'https://example.com' 
  --all-tables-with-prefix 
  --precise 
  --skip-columns=guid 
  --report-changed-only 
  --dry-run</code></pre>



<p class="wp-block-paragraph">Read the dry-run output before you commit. Each flag earns its place: <code>--all-tables-with-prefix</code> catches plugin tables that share your prefix and would otherwise be skipped, <code>--precise</code> forces a full unserialize pass on every column instead of the faster string-level path, and <code>--skip-columns=guid</code> leaves the GUID column alone because feed readers use it as an identifier, not as a URL to fetch. Drop <code>--dry-run</code> to apply.</p>



<p class="wp-block-paragraph">Flush the object cache afterwards if the site uses one. A stale Redis or Memcached entry will happily serve you the old values and convince you the replacement failed.</p>



<h2 class="wp-block-heading">TLS at fleet scale: where Let&#8217;s Encrypt rate limits actually bite</h2>



<p class="wp-block-paragraph">Twenty certificates in one weekend is where a limit you have never noticed becomes the thing that blocks you.</p>



<p class="wp-block-paragraph">Let&#8217;s Encrypt publishes its limits and they are worth reading properly before a fleet move. Two of them are the ones that catch migrations.</p>



<ul class="wp-block-list">
<li><strong>New certificates per registered domain.</strong> Up to 50 certificates per registered domain every 7 days, counted globally across all accounts. If your twenty sites are twenty separate registered domains, you are nowhere near it. If they are subdomains of one or two parent domains, which is common for an agency fleet, you can burn through it.</li>
<li><strong>Authorization failures per identifier per account.</strong> Up to 5 failures per hostname per account per hour, refilling at one every 12 minutes. This is the one that actually bites, because it is triggered by the exact thing you are doing: requesting a certificate for a hostname whose DNS still points at the old server, so the HTTP-01 challenge is served by the wrong machine and fails.</li>
</ul>



<p class="wp-block-paragraph">Order matters as a result. If you issue before DNS moves, HTTP-01 fails. If you issue after DNS moves, visitors hit a certificate error in the gap. Three ways out, in the order I reach for them:</p>



<ol class="wp-block-list">
<li><strong>DNS-01 validation.</strong> Proves control through a TXT record instead of an HTTP request, so it does not care where the A record points. You can issue every certificate before touching DNS. This is the clean answer if your DNS provider has an API your ACME client supports.</li>
<li><strong>Cut DNS first, issue immediately after.</strong> Simple, and the exposure is short if you are watching. Batch it so you are never waiting on twenty challenges at once.</li>
<li><strong>Copy the existing certificates and keys across.</strong> Buys you a bridge until the new server can renew on its own. Only viable if you control the old server&#8217;s filesystem and the certificates have real time left.</li>
</ol>



<p class="wp-block-paragraph">Whichever you choose, test the plumbing against the staging environment first. Most ACME clients expose this as a dry-run mode, and it exists precisely so you can fail as many times as you like without spending production budget.</p>



<p class="wp-block-paragraph">One more thing that catches people: if any site has HSTS enabled with a long max-age, browsers that have seen it will refuse to fall back to HTTP. There is no grace period and no way to talk them out of it. Those sites need a valid certificate on the new server from the first request, which pushes them firmly toward DNS-01 or a copied certificate.</p>



<h2 class="wp-block-heading">Planning the cutover order for a bulk WordPress migration</h2>



<p class="wp-block-paragraph">Do not move twenty sites at once. Batch them, and batch by risk rather than alphabetically or by size.</p>



<ol class="wp-block-list">
<li><strong>Batch one: the throwaways.</strong> Two or three low-traffic sites you own or that nobody would notice. This batch exists to prove the stack, not to make progress.</li>
<li><strong>Batch two: the ordinary majority.</strong> Standard brochure and content sites, no checkout, no logins that matter.</li>
<li><strong>Batch three: the ones with writes.</strong> Stores, membership sites, anything with a form feeding a sales process.</li>
<li><strong>Batch four: the awkward ones.</strong> Custom stacks, odd PHP requirements, third-party integrations that whitelist your server IP.</li>
</ol>



<p class="wp-block-paragraph">Stop between batches. Look at the error logs on the new server, not just the homepages. Twenty sites failing the same way is one problem; catching it after three is a fifteen-minute fix, catching it after twenty is a rollback.</p>



<p class="wp-block-paragraph">The per-site sequence inside a batch:</p>



<ol class="wp-block-list">
<li>Drop the TTL on that domain&#8217;s records to a low value, at least a day ahead.</li>
<li>Run the delta rsync.</li>
<li>Dump, transfer and import the database.</li>
<li>Run search-replace if the URL is changing at all.</li>
<li>Test through a hosts-file entry or a resolved request, before DNS moves.</li>
<li>Change the A and AAAA records.</li>
<li>Put the old copy read-only.</li>
<li>Verify from outside, then move to the next site.</li>
</ol>



<p class="wp-block-paragraph">Step five is the one people skip and should not. You can send a request to the new server as if DNS had already changed, without changing anything:</p>



<pre class="wp-block-code"><code>curl -sSI --resolve example.com:443:NEW_SERVER_IP https://example.com/
curl -sS --resolve example.com:443:NEW_SERVER_IP https://example.com/wp-login.php | head -n 20</code></pre>



<p class="wp-block-paragraph">That gives you real status codes, real redirects and a real TLS handshake against the new box while the live site is untouched. If that returns a 301 loop or a 500, you have found it before any visitor did.</p>



<h2 class="wp-block-heading">Cron, email, and the things that run twice</h2>



<p class="wp-block-paragraph">During the overlap window you have two live copies of every site. Both have a database. Both have a scheduler.</p>



<p class="wp-block-paragraph">WordPress schedules through <code>wp-cron.php</code>, fired by visitor traffic. The old server still gets traffic, so it still fires cron, so it still sends the newsletter, still processes the subscription renewal, still runs the backup plugin. Subscribers get things twice. Worse, the site&#8217;s system crontab came across in your file sync and is now also running on the new box.</p>



<p class="wp-block-paragraph">Handle it explicitly. On the old copy, disable the loopback scheduler when you make it read-only:</p>



<pre class="wp-block-code"><code>define( 'DISABLE_WP_CRON', true );</code></pre>



<p class="wp-block-paragraph">And on the new server, set up a real system cron per site rather than leaving it to traffic, because a site with no traffic yet has no scheduler at all:</p>



<pre class="wp-block-code"><code>wp --path=/home/user/domains/example.com/public_html cron event run --due-now</code></pre>



<p class="wp-block-paragraph">Wrap that in a cron entry or a systemd timer at whatever interval the site needs, and check <code>wp cron event list</code> before assuming nothing important is queued.</p>



<p class="wp-block-paragraph">Email is the parallel problem and it fails quietly in the other direction. Your sites now send from a new IP. If SPF still authorizes only the old server&#8217;s address, and DKIM keys did not come across, and DMARC is set to anything stricter than none, your transactional mail starts landing in spam. Nothing bounces visibly. Password resets just stop arriving and you hear about it in support tickets a week later.</p>



<p class="wp-block-paragraph">Three records to check per domain. MX is the one not to touch casually: if mail lives elsewhere and you replace a zone wholesale, the mailboxes go with it.</p>



<ul class="wp-block-list">
<li><strong>MX:</strong> unchanged unless mail is genuinely moving with you.</li>
<li><strong>SPF:</strong> add the new server&#8217;s IP before cutover, remove the old one after the overlap window closes, not during.</li>
<li><strong>DKIM:</strong> keys are per-server. Either copy them or regenerate and publish the new public key.</li>
</ul>



<p class="wp-block-paragraph">The more resilient answer is to stop sending through the server at all. Routing WordPress mail through an authenticated SMTP relay means outbound deliverability stops depending on which machine the site happens to live on, which makes the next migration a non-event.</p>



<h2 class="wp-block-heading">Troubleshooting the weekend</h2>



<p class="wp-block-paragraph"><strong>Redirect loop on the new server.</strong> Usually <code>siteurl</code> and <code>home</code> disagreeing with what the vhost or reverse proxy is doing about HTTPS. Check both values with <code>wp option get siteurl</code> and <code>wp option get home</code>. If the site sits behind a proxy or CDN that terminates TLS, WordPress may see HTTP on the back end and redirect forever.</p>



<p class="wp-block-paragraph"><strong>Site loads, media does not.</strong> Either uploads did not finish syncing, ownership is wrong, or database paths still reference the old host. Compare a file listing on both sides before assuming it is a database problem.</p>



<p class="wp-block-paragraph"><strong>Widgets or theme options empty after import.</strong> This is the serialization damage described earlier. It is not recoverable by re-running the replacement. Restore the database from the dump and redo the URL change with WP-CLI.</p>



<p class="wp-block-paragraph"><strong>Certificate issuance failing repeatedly.</strong> Confirm what the challenge is actually hitting before retrying, because each failure spends part of your hourly authorization budget.</p>



<pre class="wp-block-code"><code>dig +noall +answer example.com A
dig +noall +answer @1.1.1.1 example.com A
openssl s_client -connect NEW_SERVER_IP:443 -servername example.com &lt; /dev/null 2&gt;/dev/null | openssl x509 -noout -subject -dates</code></pre>



<p class="wp-block-paragraph">The first query uses your local resolver, the second asks a public one directly, and the difference between them tells you whether you are looking at a real propagation state or your own DNS cache.</p>



<p class="wp-block-paragraph"><strong>Site fine, admin slow.</strong> Often a plugin trying to reach an external service that whitelisted the old IP. Look for API integrations, license checks and payment gateways in the manifest&#8217;s awkward-ones column.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Lowering TTL on cutover day. The old value is still cached; the change has no effect for as long as the old TTL says.</li>
<li>Cancelling the old server as soon as the sites load. Keep it running, read-only, for the whole overlap plus a margin. It is your rollback.</li>
<li>Replacing an entire DNS zone instead of editing the A record, and taking MX, SPF and verification TXT records with it.</li>
<li>Running search-replace before importing, or on the SQL file instead of the live database.</li>
<li>Testing only the homepage. Homepages are static and cached. Test a login, a form submission, an admin page and an uploaded image.</li>
<li>Leaving both schedulers live and finding out through duplicate customer emails.</li>
<li>Assuming the new server&#8217;s PHP extension set matches the old one because the version number does.</li>
<li>Treating rollback as a fleet-level decision. Per-site rollback is what keeps one bad site from stalling nineteen good ones.</li>
</ul>



<h2 class="wp-block-heading">Best practices for a fleet move</h2>



<ul class="wp-block-list">
<li>Write the manifest first and update it as you go. It is the migration record and the handover document.</li>
<li>Take a restorable backup of each site to somewhere off both servers before you start, and test restoring one. A backup you have not restored is a hypothesis.</li>
<li>Warm sync early, delta sync at cutover, dump databases last. Batch by risk, and prove the stack on sites nobody will miss.</li>
<li>Verify every site from outside with a resolved request before its DNS moves.</li>
<li>Point uptime monitoring at the new IP before cutover, not after. UptimeRobot or Better Stack will tell you about a broken site faster than the client will.</li>
<li>Make the old copy read-only at the moment of DNS change, not at the end of the weekend.</li>
<li>Keep a rollback note per site: which records changed, what they were before, who can revert them.</li>
<li>Re-check cache configuration after the move rather than assuming it carried over, whether that is WP Rocket, WP Fastest Cache or a server-level layer.</li>
<li>Retire the old server on a scheduled date with a final archived backup, not by forgetting to renew it.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is a bulk WordPress migration of 20 sites realistic in one weekend?</h3>



<p class="wp-block-paragraph">Yes, if the preparation happens in the week before. The weekend itself should be delta syncs, database moves and DNS changes. If you are still installing PHP extensions on Saturday morning, the weekend is not long enough.</p>



<h3 class="wp-block-heading">How low should I set the TTL, and how far ahead?</h3>



<p class="wp-block-paragraph">A low value like 300 seconds, set at least 24 to 48 hours before cutover so the previous TTL has fully expired everywhere. Going much lower than 300 buys little and increases query load. Raise it back after the migration settles.</p>



<h3 class="wp-block-heading">Will moving hosts hurt my search rankings?</h3>



<p class="wp-block-paragraph">Changing the server behind an unchanged domain is not itself a ranking event. What hurts is what goes wrong during it: 5xx responses while crawlers visit, failed certificates, redirect loops, a staging copy left indexable. Keep URLs identical, keep the site responding, and check Search Console afterwards.</p>



<h3 class="wp-block-heading">Can I use a migration plugin instead of the command line for this?</h3>



<p class="wp-block-paragraph">For one site, plugins are a reasonable choice and handle serialized data correctly. At twenty the arithmetic changes: twenty manual export and import cycles through a browser, with no dry run and no delta sync. The command line is repeatable and scriptable, which is the point at fleet scale.</p>



<h3 class="wp-block-heading">How long should I keep the old VPS running?</h3>



<p class="wp-block-paragraph">At least a week after the last site cuts over, in read-only mode. Some problems only appear over a full traffic cycle: weekly scheduled jobs, integrations that call in periodically, mail arriving at the old MX. Keep an archived backup after you destroy it.</p>



<h3 class="wp-block-heading">What if one site breaks after cutover?</h3>



<p class="wp-block-paragraph">Revert that site&#8217;s A record and lift the read-only restriction on the old copy. That is why the old server stays up and why rollback is per site. If you sync the database back afterwards, be careful not to overwrite writes that landed on the new server in the meantime.</p>



<h3 class="wp-block-heading">Do I need to move email at the same time?</h3>



<p class="wp-block-paragraph">No, and separating them is usually wiser. Move web first, confirm it is stable, then plan mailbox migration as its own change with its own window. Combining them means a single mistake in one DNS zone takes down both.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">A bulk WordPress migration is not twenty small migrations. It is one process run twenty times, and the difference is that at scale the visible failures get caught while the invisible ones do not.</p>



<p class="wp-block-paragraph">The copy is the easy part. rsync and WP-CLI are reliable, well documented and boring. What costs you is the overlap window: two servers both answering, both writing, both scheduling, for hours after you have mentally finished. Decide before the weekend what happens to writes on the old server, make that decision per site, and act on it at the moment DNS changes rather than at the end.</p>



<p class="wp-block-paragraph">Do that, batch by risk, and keep a rollback that works at the level of a single site, and twenty sites in a weekend is an ordinary piece of work rather than a gamble.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help moving a fleet of sites?</h2>



<p class="wp-block-paragraph">Migrations are a good thing to have a second pair of hands on, because the expensive mistakes are the quiet ones. This is work I do regularly:</p>



<ul class="wp-block-list">
<li>Auditing an existing fleet and producing the migration manifest, batch order and rollback plan before anything moves</li>
<li>Provisioning and hardening the destination VPS, including multiple PHP versions, control panel setup and firewall rules</li>
<li>Running the sync and database cutover with WP-CLI, including URL replacement on serialized data</li>
<li>Planning TLS issuance around ACME rate limits, including DNS-01 validation and automated renewal</li>
<li>Sorting out post-migration email: SPF, DKIM, DMARC and moving transactional mail onto an authenticated relay</li>
<li>Sitting in as the on-call engineer for the cutover window itself, or reviewing a plan you have already written</li>
</ul>



<p class="wp-block-paragraph">If you are planning a move and something in it worries you, send me the concrete thing: the site list, the current vhost config, the rsync output, the certbot error. I would rather look at the actual output than talk in general terms.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/bulk-wordpress-migration-vps/">Bulk WordPress Migration: Moving 20 Sites to a New VPS in One Weekend</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>DirectAdmin PHP-FPM Pools: Why One WordPress Site Takes Down the Others</title>
		<link>https://john-nessime.com/blog/system-administration/directadmin-php-fpm-pools-wordpress-tuning/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 23 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[CustomBuild]]></category>
		<category><![CDATA[DirectAdmin]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OPcache]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[PHP-FPM]]></category>
		<category><![CDATA[Shared Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[VPS]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=246</guid>

					<description><![CDATA[<p>DirectAdmin gives each user one PHP-FPM pool, not each site. That single design fact explains most of the 502s on a busy shared box. Here is how to find the pool files, size pm.max_children from real memory numbers, set per-site limits that WordPress cannot override, and make the changes survive the next rewrite_confs.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/directadmin-php-fpm-pools-wordpress-tuning/">DirectAdmin PHP-FPM Pools: Why One WordPress Site Takes Down the Others</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket said &#8220;the shop is throwing 502s again.&#8221; The shop was fine. What was not fine was the half-forgotten news site sitting on the same DirectAdmin account, running a broken cron that fired an uncached admin-ajax call every few seconds. It had eaten every worker in the pool, and WooCommerce was queued behind it, waiting for a process that was never going to free up in time.</p>



<p class="wp-block-paragraph">That is the thing about <strong>DirectAdmin PHP-FPM pools</strong> that catches people out. You think you are tuning a site. You are not. You are tuning a user, and every domain that user owns is sharing the same worker budget, the same queue, and the same memory ceiling.</p>



<p class="wp-block-paragraph">This post covers where DirectAdmin actually keeps the pool config, how to size <code>pm.max_children</code> from numbers your server gives you rather than from a blog post&#8217;s magic value, which per-site PHP limits are worth setting and which are theatre, why OPcache does not behave the way you expect on a multi-tenant box, and how to make all of it survive the next CustomBuild run.</p>



<h2 class="wp-block-heading">The design fact that explains most of your 502s</h2>



<p class="wp-block-paragraph">DirectAdmin generates one FPM pool per user account, per PHP version. The pool is named after the Linux user. It listens on a Unix socket named after the user. It runs as that user. Apache or Nginx hands PHP requests to that socket for every domain and subdomain in the account.</p>



<p class="wp-block-paragraph">So if a reseller client has twelve domains under one username, those twelve sites share a single value of <code>pm.max_children</code>. There is no per-domain pool. This is a long-standing feature request rather than an oversight, and it has a real consequence: <strong>the noisiest site in an account sets the availability of every other site in that account.</strong></p>



<p class="wp-block-paragraph">Once you have internalised that, the fix for a lot of &#8220;mystery 502&#8221; tickets stops being a config tweak and starts being an account layout decision. If a site matters commercially, give it its own DirectAdmin user. That is the cleanest isolation the panel offers without going off-piste, and it costs you nothing but a bit of admin tidiness.</p>



<p class="wp-block-paragraph">The alternative, if you have User jails enabled, is DirectAdmin&#8217;s isolated FPM mode. That runs a separate <code>php-fpm</code> master process per jailed user instead of a shared master with many pools:</p>



<pre class="wp-block-code"><code>da config-set isolated_fpm 1
systemctl restart directadmin
da taskq --run 'action=rewrite&amp;value=httpd'</code></pre>



<p class="wp-block-paragraph">It only applies to users who already have Jailed enabled. The trade-off is memory: one master process per user, plus a separate OPcache region for each, adds up quickly. DirectAdmin mitigates that by letting idle per-user FPM instances exit after a period of inactivity, but on a box with sixty accounts you will feel it. I would reach for this on a server hosting a handful of paying clients, not on a general shared box.</p>



<h2 class="wp-block-heading">Where the pool config actually lives</h2>



<p class="wp-block-paragraph">This trips up almost everyone the first time, because there are two files with nearly the same name and only one of them matters for user sites.</p>



<ul class="wp-block-list">

<li><code>/usr/local/php83/etc/php-fpm.conf</code> is the master config for that PHP version. Editing it by hand is how you learn that CustomBuild overwrites it.</li>


<li><code>/usr/local/directadmin/data/users/USERNAME/php/php-fpm83.conf</code> is the generated per-user pool. This is the file that governs a customer&#8217;s sites.</li>


<li><code>/usr/local/directadmin/data/templates/php-fpm.conf</code> is the template those per-user files are rendered from.</li>

</ul>



<p class="wp-block-paragraph">Substitute your PHP version for <code>83</code> throughout. To see what every account is currently running:</p>



<pre class="wp-block-code"><code>ls /usr/local/directadmin/data/users/*/php/php-fpm*.conf

grep -H 'pm.max_children' /usr/local/directadmin/data/users/*/php/php-fpm*.conf</code></pre>



<p class="wp-block-paragraph">The second command is the one I run first on any server I have just inherited. It gives you the entire worker budget of the box on one screen, and it is usually the moment you discover that forty accounts are all sitting on the stock default.</p>



<p class="wp-block-paragraph">The DirectAdmin template ships with <code>pm = ondemand</code> and a low default child count, driven by a <code>MAX_CHILDREN</code> token. Ondemand means no workers exist until a request arrives, and idle workers are reaped after a timeout. On a shared box with many mostly-idle accounts, that is the right default and I would leave it alone. On a server dedicated to one busy WordPress site, the spawn latency on the first request after an idle period is real and switching to <code>dynamic</code> is defensible. Most people who switch to <code>dynamic</code> on a shared server are trading memory they do not have for a latency win they will not notice.</p>



<h2 class="wp-block-heading">Sizing pm.max_children without guessing</h2>



<p class="wp-block-paragraph">Every tuning guide gives you a formula. The formula is fine. The input everyone gets wrong is worker memory, because they use a number from someone else&#8217;s server instead of measuring their own. A lean WordPress install and the same install with a page builder, a security plugin and WooCommerce are not remotely the same workload.</p>



<p class="wp-block-paragraph">Measure it. This averages the resident memory of the workers currently running in one pool:</p>



<pre class="wp-block-code"><code>ps -eo rss,args | grep '&#91;p]hp-fpm: pool wpuser' 
  | awk '{sum+=$1; n++} END {if (n) printf "%d workers, avg %.0f MBn", n, sum/n/1024}'</code></pre>



<p class="wp-block-paragraph">The bracket trick in the grep pattern stops grep matching its own process. <code>rss</code> is resident set size in kilobytes, which is why the awk divides twice. Run it during a busy period, not at three in the morning, and run it a few times.</p>



<p class="wp-block-paragraph">Two honest caveats about RSS. It overstates real consumption, because workers forked from the same master share a lot of pages, including the OPcache region. And it understates your worst case, because a worker that has not yet hit a memory-hungry request looks cheap. Treat the average as a planning figure, not a guarantee, and leave headroom.</p>



<p class="wp-block-paragraph">Then the arithmetic:</p>



<ol class="wp-block-list">

<li>Take total RAM and subtract what MySQL or MariaDB, the web server, mail, and the panel itself need. On a WordPress box the database is usually the second-largest consumer and it is not optional.</li>


<li>Reserve a further slice for page cache and burst. I aim to leave a comfortable margin free rather than allocating to the last megabyte.</li>


<li>Divide what is left by your measured per-worker figure. That is your ceiling for <em>all</em> pools combined.</li>


<li>Split that ceiling across accounts by actual traffic, not evenly.</li>

</ol>



<p class="wp-block-paragraph">Step three is where people go wrong on cheap VPS plans. If you are running a dozen WordPress sites on one of the low-RAM tiers from a provider like Contabo or InterServer, the honest answer is often that you cannot give every account a generous pool and you need to either cut the site count or move up a tier. Setting <code>pm.max_children</code> to a number your RAM cannot back does not buy capacity. It buys the OOM killer, and the OOM killer usually takes the database with it, which turns a slow site into a broken one.</p>



<p class="wp-block-paragraph">To raise the server-wide default:</p>



<pre class="wp-block-code"><code>da config-set php_fpm_max_children_default 30
systemctl restart directadmin
da build rewrite_confs
systemctl restart php-fpm83</code></pre>



<p class="wp-block-paragraph">For a single account, go to <em>Admin Level &rarr; Custom HTTPD Configurations &rarr; domain.com</em> and open the <code>php-fpm</code> entry for that PHP version. In the <code>|CUSTOM1|</code> textarea, set the token:</p>



<pre class="wp-block-code"><code>|?MAX_CHILDREN=40|</code></pre>



<p class="wp-block-paragraph">That syntax is a template token override, not a config line. It tells the template renderer to use 40 instead of the default when it writes <code>pm.max_children</code> for that user. Writing <code>pm.max_children = 40</code> in that box instead is the single most common mistake here, and it does something worse than nothing: the template still emits its own <code>pm.max_children</code>, you now have the directive twice in one pool, and FPM refuses to start. On a server where one broken pool file stops the whole master, that takes down every site on the box.</p>



<p class="wp-block-paragraph">Rewrite and restart after any change, then confirm the value actually landed in the generated file rather than trusting the panel.</p>



<h2 class="wp-block-heading">Per-site PHP limits, and the difference that matters</h2>



<p class="wp-block-paragraph">The <code>|CUSTOM2|</code> textarea on the same page is where PHP settings go. The syntax is FPM pool syntax, not php.ini syntax:</p>



<pre class="wp-block-code"><code>php_admin_value&#91;memory_limit] = 512M
php_admin_value&#91;max_execution_time] = 120
php_value&#91;upload_max_filesize] = 64M
php_value&#91;post_max_size] = 64M
php_value&#91;max_input_vars] = 5000</code></pre>



<p class="wp-block-paragraph">The <code>admin</code> in <code>php_admin_value</code> is not decoration. Settings written with <code>php_value</code> can be raised at runtime by the application through <code>ini_set()</code>. Settings written with <code>php_admin_value</code> cannot. That distinction is the whole game on a multi-tenant server.</p>



<p class="wp-block-paragraph">WordPress and a good number of plugins will happily try to raise <code>memory_limit</code> at runtime, and some optimisation plugins push it to values that would let one bad request consume a large slice of the machine. If you want a genuine ceiling, use <code>php_admin_value</code>. If you want a sensible starting point that a developer can raise for a legitimate import job, use <code>php_value</code>. I use admin for memory and execution time, plain for upload sizes, because upload sizes are a support-ticket generator and not a stability risk.</p>



<p class="wp-block-paragraph"><code>max_input_vars</code> deserves its own note. It is the one that silently truncates a large WooCommerce product save or a menu with hundreds of items, with no error anywhere. The user reports &#8220;my changes did not save&#8221; and every log is clean. If you support content-heavy sites, raise it before someone asks.</p>



<h3 class="wp-block-heading">Letting site owners set their own</h3>



<p class="wp-block-paragraph">Under FPM there is no <code>.htaccess</code> route for PHP settings, because FPM is a separate process and never sees the file. DirectAdmin enables the per-directory alternative by default: a <code>.user.ini</code> file in <code>public_html</code>, controlled by the <code>user_ini.filename</code> setting. It handles the <code>PHP_INI_PERDIR</code> and <code>PHP_INI_USER</code> class of settings only, so it will never override anything you set with <code>php_admin_value</code>. That is the point of it.</p>



<h3 class="wp-block-heading">The WP-CLI gap</h3>



<p class="wp-block-paragraph">None of this applies to WP-CLI. Cron jobs and shell commands run through the CLI SAPI and read the CLI php.ini, not your pool. So a plugin update that works fine in the browser can die on memory in a cron-driven <code>wp cron event run</code>, and you will hunt through the pool config finding nothing wrong. Check the CLI limits separately when a scheduled task fails but the same action succeeds in wp-admin.</p>



<h2 class="wp-block-heading">OPcache is shared, and that is the invisible one</h2>



<p class="wp-block-paragraph">OPcache allocates its shared memory region when the FPM master starts, before any pool exists. Every pool under that master inherits the same region. You cannot meaningfully give one account its own OPcache budget through a pool directive, because by the time the pool is read the memory has already been carved out.</p>



<p class="wp-block-paragraph">Two consequences, and the second is the one nobody plans for.</p>



<p class="wp-block-paragraph">First, capacity. The default OPcache size is comfortable for one application and thin for thirty WordPress installs, each with its own core, theme and plugin tree. When it fills, OPcache starts evicting and eventually restarts, and every site on the version gets slower at once with nothing in any error log to explain it. Watch the wasted-memory and restart counters rather than guessing.</p>



<p class="wp-block-paragraph">Second, the sizing you actually need scales with the <em>total</em> number of PHP files across all accounts on that PHP version, not the biggest site. Consolidating everyone onto one PHP version looks tidy and quietly makes this worse. Spreading accounts across two or three PHP versions gives each master its own region, which is an underrated side effect of not forcing everyone to upgrade on the same day.</p>



<p class="wp-block-paragraph">OPcache is configured through CustomBuild rather than the pool. Edit the customised copy under CustomBuild&#8217;s <code>custom</code> directory so your changes survive updates, then rebuild and restart the FPM master.</p>



<h2 class="wp-block-heading">Making changes survive the next rebuild</h2>



<p class="wp-block-paragraph">Anything you type directly into a generated file is temporary. The next <code>rewrite_confs</code>, PHP update or panel update will flatten it, usually weeks later, and the regression will look like it came from nowhere.</p>



<p class="wp-block-paragraph">There are three durable places to put changes, in increasing order of blast radius:</p>



<ul class="wp-block-list">

<li><strong>Per user, all PHP versions.</strong> <code>/usr/local/directadmin/data/users/USERNAME/php/php-fpm.conf.custom1</code> and <code>.custom2</code>. These feed the same CUSTOM tokens as the panel textareas, so the settings follow the account when the user switches PHP version in Domain Setup. This is the one I reach for first.</li>


<li><strong>Server-wide, all users, all versions.</strong> <code>/usr/local/directadmin/data/templates/custom/php-fpm.conf.custom1</code> and <code>.custom2</code>. If either exists, its contents are injected into every pool file DirectAdmin writes. Good for a baseline you want everywhere.</li>


<li><strong>A full custom template.</strong> Copy <code>/usr/local/directadmin/data/templates/php-fpm.conf</code> to the <code>custom</code> subdirectory and edit it. This is the only route for pool directives that have no token, but you now own that file forever and stop receiving upstream template improvements. Use it when nothing else works, and leave a comment at the top saying why.</li>

</ul>



<p class="wp-block-paragraph">Global first, then per-user on top. Both land in the same token, so a per-user value does not replace a global one, it appends. For a directive that can only appear once in a pool, that is a duplicate and a failed restart.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">&#8220;server reached max_children setting&#8221;</h3>



<p class="wp-block-paragraph">The pool name in that warning tells you which account, not which site. Find the log first rather than guessing at a path:</p>



<pre class="wp-block-code"><code>grep -i 'error_log' /usr/local/php83/etc/php-fpm.conf</code></pre>



<p class="wp-block-paragraph">Before raising the limit, check whether the requests are legitimate. Cross-reference the timestamps against each domain&#8217;s access log in that account. Nine times out of ten it is one of three things: an uncached <code>admin-ajax.php</code> loop from a plugin, a bot crawling faceted search or filtered archive URLs that bypass page cache, or <code>wp-cron.php</code> firing on every request on a site with a heavy scheduled task. Raising <code>pm.max_children</code> to absorb bot traffic is paying for garbage with RAM.</p>



<h3 class="wp-block-heading">502 or 504 with no PHP error</h3>



<p class="wp-block-paragraph">The request is dying at the FPM boundary rather than inside PHP. Usual causes are a worker killed by <code>request_terminate_timeout</code>, a full listen queue, or the master failing to start after a config change. Check the FPM service status before you check anything in WordPress. If FPM did not start at all, the panel will still show your settings saved and every site on that PHP version will be down.</p>



<h3 class="wp-block-heading">Changes in the panel that do nothing</h3>



<p class="wp-block-paragraph">Read the generated per-user file after every change. If the value is not in there, the rewrite did not run or the token was wrong. If it is in there and PHP still reports something else, the master was not restarted, or something later in the pool is overriding it. A temporary <code>phpinfo()</code> file served by the site in question settles the argument in seconds. Delete it immediately afterwards.</p>



<h3 class="wp-block-heading">Session or temp files that are not where you left them</h3>



<p class="wp-block-paragraph">If PHP writes to <code>/tmp</code> and you cannot see the files over SSH, that is systemd&#8217;s <code>PrivateTmp</code> giving the FPM service its own private namespace. Nothing is broken. The files exist, in a mount only that process can see.</p>



<h3 class="wp-block-heading">disable_functions that will not budge</h3>



<p class="wp-block-paragraph"><code>disable_functions</code> is special. From a pool you can add to the list but not remove from it, because the php.ini value has already been applied. To allow a function for one account you have to stop setting it in php.ini at all and drive the whole thing from templates instead. DirectAdmin documents that pattern, and it is worth reading before you start rather than after you have half-done it, because the intermediate state removes protection server-wide.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">

<li>Editing <code>/usr/local/phpXX/etc/php-fpm.conf</code> directly and being surprised when it reverts.</li>


<li>Writing <code>pm.max_children = 40</code> into a CUSTOM box instead of the <code>|?MAX_CHILDREN=40|</code> token, producing a duplicate directive and a dead master.</li>


<li>Sizing workers from a memory figure found online rather than measured on the box.</li>


<li>Using <code>php_value</code> for <code>memory_limit</code> on shared hosting, then wondering how a plugin raised it to 2G.</li>


<li>Assuming pool settings apply to WP-CLI and cron.</li>


<li>Raising limits to absorb bot traffic instead of blocking or caching it upstream.</li>


<li>Putting a client&#8217;s revenue site in the same account as their abandoned projects.</li>

</ul>



<h2 class="wp-block-heading">Best practices for tuning DirectAdmin PHP-FPM pools</h2>



<ul class="wp-block-list">

<li>One DirectAdmin user per site that matters. Isolation you get for free beats isolation you have to engineer.</li>


<li>Keep <code>ondemand</code> on shared servers. Only move to <code>dynamic</code> when a single busy site owns the box.</li>


<li>Set a global baseline in the custom template, then override per user. Do not hand-tune forty files.</li>


<li>Use <code>php_admin_value</code> for anything that protects the server and <code>php_value</code> for anything that is only a convenience.</li>


<li>Cut PHP requests before you add workers. Full-page caching, a CDN in front, and Cloudflare or similar handling bots removes far more load than any pool setting.</li>


<li>Disable WordPress&#8217;s request-triggered cron and drive it from a real system cron, so scheduled work stops competing with visitors for workers.</li>


<li>Alert on saturation, not just uptime. Worker utilisation per pool and PHP-FPM error log entries are the signals worth having in Netdata, Prometheus, or whatever you already run. A site that is queueing looks perfectly healthy to an HTTP check.</li>


<li>Re-measure after major plugin changes. A page builder or a new security plugin moves per-worker memory enough to invalidate your arithmetic.</li>

</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can I create a separate PHP-FPM pool for each domain in DirectAdmin?</h3>



<p class="wp-block-paragraph">Not through the panel. Pools are generated per user, per PHP version, and every domain in the account shares one. The supported ways to get separation are to move the domain to its own DirectAdmin user, or to enable isolated FPM mode for jailed users, which gives each user their own master process. Anything else means custom systemd units you maintain yourself, outside the panel&#8217;s knowledge.</p>



<h3 class="wp-block-heading">What is a good pm.max_children value for WordPress?</h3>



<p class="wp-block-paragraph">There is no portable number, because the input is your measured per-worker memory and your available RAM after the database. Measure the average resident size of your workers under real traffic, subtract everything else the server needs from total RAM, and divide. A lean brochure site and a WooCommerce store with thirty plugins can differ by a factor of three or more.</p>



<h3 class="wp-block-heading">Where does DirectAdmin store PHP-FPM pool configuration?</h3>



<p class="wp-block-paragraph">Generated per-user pools live at <code>/usr/local/directadmin/data/users/USERNAME/php/php-fpmXX.conf</code>, rendered from the template at <code>/usr/local/directadmin/data/templates/php-fpm.conf</code>. Do not edit either directly. Put durable changes in the custom files under the user&#8217;s <code>php</code> directory or in the <code>templates/custom</code> directory.</p>



<h3 class="wp-block-heading">Why do my PHP settings disappear after a CustomBuild update?</h3>



<p class="wp-block-paragraph">Because they were written into a generated file. CustomBuild and DirectAdmin regenerate those files on update and on any config rewrite. Only the custom template files and the CUSTOM token areas persist.</p>



<h3 class="wp-block-heading">Does .htaccess work for PHP settings under PHP-FPM?</h3>



<p class="wp-block-paragraph">No. FPM is a separate process from the web server and never reads <code>.htaccess</code>. Use a <code>.user.ini</code> file in the web root for the settings PHP allows at that level, or set the rest in the pool.</p>



<h3 class="wp-block-heading">Should I switch from ondemand to dynamic or static?</h3>



<p class="wp-block-paragraph">On a shared server, no. Ondemand exists precisely so that dozens of idle pools cost you nothing. On a server dedicated to one high-traffic site, <code>dynamic</code> removes cold-start latency and <code>static</code> gives the most predictable memory profile. The question to ask is whether your idle pools outnumber your busy ones.</p>



<h3 class="wp-block-heading">Can I give one WordPress site its own OPcache?</h3>



<p class="wp-block-paragraph">Not from the pool config. OPcache&#8217;s shared memory is allocated by the FPM master at startup and shared by every pool under it. The practical routes are isolated FPM mode, which gives jailed users their own master, or moving the site to a different PHP version so it sits under a different master.</p>



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">DirectAdmin PHP-FPM pools are a per-user boundary, not a per-site one. Every tuning decision that follows, how many workers to allow, whether a memory limit should be a suggestion or a wall, whether a client&#8217;s twelve domains belong in one account, comes out of that single fact.</p>



<p class="wp-block-paragraph">Measure your workers before you size them, use the CUSTOM token files so your work survives the next rebuild, and watch pool saturation rather than waiting for the 502 ticket. The site that goes down is rarely the site that caused it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a hand with a DirectAdmin server?</h2>



<p class="wp-block-paragraph">Most of the work I do on DirectAdmin boxes is this kind of thing: sites that are fine in isolation and fall over together. If that sounds familiar, I can help with:</p>



<ul class="wp-block-list">

<li>Auditing every pool on a server and producing a worker budget that matches the RAM you actually have</li>


<li>Tracking down which domain in a shared account is saturating the pool, and why</li>


<li>Building a global custom template so per-user tuning stops being forty hand-edited files</li>


<li>Splitting a multi-site DirectAdmin account into isolated users without breaking mail, DNS or SSL</li>


<li>OPcache and per-version sizing on servers hosting many WordPress installs</li>


<li>Wiring up pool-level monitoring and alerts so saturation shows up before the ticket does</li>

</ul>



<p class="wp-block-paragraph">Send me a pool file, the output of that RSS command, or the last hour of your PHP-FPM error log, and I will tell you what I see.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/directadmin-php-fpm-pools-wordpress-tuning/">DirectAdmin PHP-FPM Pools: Why One WordPress Site Takes Down the Others</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Why Your WordPress Emails Vanish: SMTP, SPF, DKIM and DMARC</title>
		<link>https://john-nessime.com/blog/technical-guides/wordpress-email-deliverability-spf-dkim-dmarc/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 11 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[DKIM]]></category>
		<category><![CDATA[DMARC]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[DNS Records]]></category>
		<category><![CDATA[Email Authentication]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[SPF]]></category>
		<category><![CDATA[Transactional Email]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[wp_mail]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=180</guid>

					<description><![CDATA[<p>WordPress tells you the mail was sent. The customer never got it. This is a working guide to the four places WordPress email deliverability actually breaks: PHP mail() transport, SPF lookup limits, DKIM alignment, and DMARC policy. Includes real diagnostic commands, a troubleshooting order, and what changed now that DMARC has moved onto the IETF standards track.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/wordpress-email-deliverability-spf-dkim-dmarc/">Why Your WordPress Emails Vanish: SMTP, SPF, DKIM and DMARC</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket says &#8220;customer never received the password reset.&#8221; You check the site. The form submits fine. You trigger a reset yourself and it lands in your inbox in four seconds. Nothing in the error log. Plugin says the mail was sent. Everything is green.</p>



<p class="wp-block-paragraph">So you close the ticket. Then it happens again next week, to a different customer, on a different mailbox provider.</p>



<p class="wp-block-paragraph">This is the shape of almost every WordPress email deliverability problem I&#8217;ve had to unpick. The site is not broken and the mail is genuinely leaving. It is being evaluated somewhere downstream and quietly filed into spam by a receiver that has decided it cannot prove who sent it. Nothing in WordPress will ever tell you this happened.</p>



<p class="wp-block-paragraph">This post covers the four places that failure actually lives: how the message leaves your server, whether SPF survives evaluation, whether DKIM signs and <em>aligns</em>, and what DMARC does with the result. It is organised by failure family rather than by protocol tour, because the protocols are individually well documented and the failures are not.</p>



<h2 class="wp-block-heading">The failure mode that costs you money: silent success</h2>



<p class="wp-block-paragraph">Start with the thing that makes this hard to debug. <code>wp_mail()</code> returns a boolean. That boolean tells you whether PHPMailer handed the message off without throwing. It tells you nothing about whether the message was delivered, and nothing at all about whether it reached an inbox rather than a spam folder.</p>



<p class="wp-block-paragraph">So &#8220;WordPress says it sent&#8221; and &#8220;the customer got it&#8221; are separated by four or five systems you have no visibility into. That gap is where the money leaks: WooCommerce order confirmations, membership welcome mails, password resets, form notifications. All of them fail the same silent way.</p>



<p class="wp-block-paragraph">So stop guessing about the boolean. WordPress fires an action when the handoff itself fails, and almost nobody hooks it:</p>



<pre class="wp-block-code"><code>// Log the real reason PHPMailer refused, instead of a bare false.
add_action( 'wp_mail_failed', function ( $error ) {
    error_log( 'wp_mail failed: ' . $error-&gt;get_error_message() );
} );</code></pre>



<p class="wp-block-paragraph">Then force a send from the command line so you are testing the mail path and not a contact form plugin:</p>



<pre class="wp-block-code"><code>wp eval 'var_dump( wp_mail( "you@example.com", "deliverability test", "body" ) );'</code></pre>



<p class="wp-block-paragraph">If that prints <code>bool(true)</code> and the mail still never arrives, you have ruled out WordPress entirely. Everything from here is transport and DNS.</p>



<h2 class="wp-block-heading">Failure family one: how the message leaves the box</h2>



<p class="wp-block-paragraph">By default <code>wp_mail()</code> falls through to PHP&#8217;s <code>mail()</code>, which shells out to the local mail transfer agent. On a shared host or a default VPS build, that means your marketing site is now an email server with none of the reputation, warm-up, or bounce handling that implies.</p>



<p class="wp-block-paragraph">Receivers are not being unreasonable about this. A message arriving from a web server IP, with no authentication, from a domain the IP has no relationship with, looks exactly like a compromised WordPress install pumping out spam. Because most of the time, it is.</p>



<h3 class="wp-block-heading">The three transport options, honestly</h3>



<p class="wp-block-paragraph"><strong>Local <code>mail()</code>.</strong> Genuine case for it: zero dependencies, no credentials to leak, and it works fine when the box already runs a properly configured MTA with correct reverse DNS and a warmed IP. Where it fails: almost everywhere else. Most hosts either block outbound port 25 or run an MTA nobody has configured for authentication. Skip it unless you own the mail infrastructure and know its reputation.</p>



<p class="wp-block-paragraph"><strong>Authenticated SMTP submission.</strong> WordPress connects to a real mail provider over port 587 with STARTTLS, or 465 with implicit TLS, and authenticates. This is the portable option, it works with any provider, and it is what I reach for first on a site I did not build. The cost is that SMTP is a chatty, connection-oriented protocol running inside a page request, so a slow or throttled provider makes checkout feel slow.</p>



<p class="wp-block-paragraph"><strong>Provider HTTP API.</strong> The plugin posts the message to an API endpoint over 443 instead of speaking SMTP. Faster, immune to hosts that block SMTP ports outbound, and usually gives you richer delivery events back. The cost is real lock-in: your sending config is now provider-specific and moving means reconfiguring, not just changing a hostname.</p>



<p class="wp-block-paragraph">On the plugin side, WP Mail SMTP and Post SMTP both cover the common providers and both have usable free tiers. For providers, transactional-focused services like Postmark, Amazon SES, Mailgun, Brevo and SendGrid all do this job well; the differences that matter are bounce webhook quality, log retention, and how quickly a human answers when your account gets throttled. If your host bundles a transactional relay, check whether it authenticates as <em>your</em> domain or the host&#8217;s shared domain before you rely on it. Providers like InterServer, Cloudways and Kinsta differ meaningfully here.</p>



<h3 class="wp-block-heading">Prove the transport before you touch DNS</h3>



<p class="wp-block-paragraph">Before blaming records, confirm the server can actually reach the relay. This opens a TLS session against the submission port and shows you the SMTP banner and advertised capabilities:</p>



<pre class="wp-block-code"><code>openssl s_client -starttls smtp -crlf -connect smtp.example.net:587</code></pre>



<p class="wp-block-paragraph"><code>-starttls smtp</code> issues the STARTTLS upgrade for you, and <code>-crlf</code> sends proper line endings so the session does not hang. If this times out, your host is filtering outbound SMTP and you want the HTTP API transport instead of a longer argument with support.</p>



<p class="wp-block-paragraph">To send a full test message end to end, <code>swaks</code> is the tool worth having on every box:</p>



<pre class="wp-block-code"><code>swaks --to you@example.com --from wp@example.com 
      --server smtp.example.net:587 --auth --tls</code></pre>



<p class="wp-block-paragraph">It prints the entire conversation, so an authentication failure or a policy rejection shows up as an actual SMTP response code rather than a shrug from a plugin.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family two: SPF passes until it silently doesn&#8217;t</h2>



<p class="wp-block-paragraph">SPF is a DNS TXT record listing who may send using your domain in the envelope sender. It looks like this:</p>



<pre class="wp-block-code"><code>v=spf1 include:_spf.google.com include:sendgrid.net ~all</code></pre>



<p class="wp-block-paragraph">Two things about that record cause more WordPress email deliverability incidents than anything else in SPF.</p>



<h3 class="wp-block-heading">The ten lookup budget</h3>



<p class="wp-block-paragraph">RFC 7208 caps SPF evaluation at ten mechanisms that require DNS lookups. <code>include</code>, <code>a</code>, <code>mx</code>, <code>ptr</code>, <code>exists</code> and <code>redirect</code> all count, and crucially so does everything nested inside someone else&#8217;s <code>include</code>. <code>ip4</code> and <code>ip6</code> cost nothing.</p>



<p class="wp-block-paragraph">Cross the limit and the receiver returns <code>permerror</code>. That is not a soft fail. The receiver could not evaluate your record at all, treats SPF as failed, and every message from the domain is affected at once.</p>



<p class="wp-block-paragraph">Here is why it bites so hard on WordPress sites specifically. Nobody adds ten includes in one sitting. Someone adds Google Workspace. Six months later the marketing team adds a newsletter platform. Then a CRM. Then a help desk. Then you add a transactional provider for the WooCommerce mail, and the record tips over. Nothing in DNS warns you, no plugin surfaces it, and the only visible signal is in DMARC aggregate reports you probably aren&#8217;t reading yet.</p>



<p class="wp-block-paragraph">Check the record and count what it actually resolves to:</p>



<pre class="wp-block-code"><code>dig +short TXT example.com</code></pre>



<p class="wp-block-paragraph">Counting nested includes by hand is tedious and error-prone, so use a checker that expands them. When you are over budget, the durable fixes are removing senders you no longer use and moving distinct sending systems onto their own subdomains, each with their own SPF record. Flattening includes into raw IPs works, but you have just taken ownership of someone else&#8217;s IP ranges and they will change without telling you.</p>



<h3 class="wp-block-heading">One record, and mind the envelope</h3>



<p class="wp-block-paragraph">Publish exactly one SPF record per domain. Two records is not additive, it is invalid, and receivers may fail the check outright.</p>



<p class="wp-block-paragraph">Also remember SPF checks the envelope sender, not the address your visitor sees. On WordPress those can easily differ, especially when a plugin sets a friendly From address while the MTA uses the system user as the envelope. If you need to control it explicitly:</p>



<pre class="wp-block-code"><code>add_action( 'phpmailer_init', function ( $phpmailer ) {
    $phpmailer-&gt;Sender = 'bounces@example.com';
} );</code></pre>



<p class="wp-block-paragraph">Setting <code>Sender</code> sets the envelope from, which is what SPF evaluates and where bounces are returned. It is not the same field as <code>From</code>, and conflating the two is a classic source of &#8220;but SPF passes when I test it&#8221; confusion.</p>



<h2 class="wp-block-heading">Failure family three: DKIM signs, but alignment is what counts</h2>



<p class="wp-block-paragraph">This is the invisible one, and if you take one thing from this post, take this.</p>



<p class="wp-block-paragraph">DKIM adds a cryptographic signature to the message. The receiver fetches your public key from DNS at the selector your provider gave you and verifies the signature held. Check yours like this:</p>



<pre class="wp-block-code"><code>dig +short TXT selector1._domainkey.example.com</code></pre>



<p class="wp-block-paragraph">Straightforward enough. Here is the part that catches people: <strong>SPF and DKIM can both pass and DMARC can still fail.</strong></p>



<p class="wp-block-paragraph">DMARC does not care that <em>a</em> domain authenticated. It cares that the domain the recipient <em>sees</em> in the From header matches the domain that authenticated. That match is called alignment, and it is the whole point of DMARC. SPF aligns when the envelope sender domain matches the From domain. DKIM aligns when the signing domain in the <code>d=</code> tag matches the From domain. You need at least one of them aligned. Not both, but at least one.</p>



<p class="wp-block-paragraph">Now picture the default setup on a WordPress site with a transactional provider. The From header says <code>orders@example.com</code>. The envelope sender is a bounce address on the provider&#8217;s own domain, so SPF authenticates the provider and does not align with you. And if you skipped the DNS records the provider asked you to add, the message is signed with the provider&#8217;s shared key, so <code>d=</code> is the provider&#8217;s domain and DKIM does not align either.</p>



<p class="wp-block-paragraph">Both checks pass. Alignment fails. DMARC fails. Every deliverability tester you paste the record into says your DNS is fine.</p>



<p class="wp-block-paragraph">The fix is nearly always the same: complete your provider&#8217;s domain authentication step so mail is signed as your own domain rather than their shared one. That usually means publishing a CNAME or TXT record at a selector under <code>_domainkey</code>, and often a CNAME for a custom return path so SPF aligns too.</p>



<h3 class="wp-block-heading">Read the headers, don&#8217;t trust the dashboard</h3>



<p class="wp-block-paragraph">Send yourself a real message from the site and open the raw source. The <code>Authentication-Results</code> header the receiver added is the ground truth. You are looking for the SPF result, the DKIM result, the <code>d=</code> domain on the DKIM signature, and the DMARC verdict. If <code>d=</code> is not your domain and the SPF domain is not your domain, you have found your problem regardless of what any dashboard claims.</p>



<p class="wp-block-paragraph">One practical note on the key itself: a 2048-bit DKIM public key does not fit in a single 255 character DNS string and has to be published as multiple concatenated strings. Most DNS providers handle this transparently. Some do not, and the symptom is a key that looks present but fails verification.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family four: DMARC policy, and what just changed</h2>



<p class="wp-block-paragraph">DMARC ties the previous two together. It tells receivers what to do when neither SPF nor DKIM aligns, and it asks them to send you reports about it.</p>



<pre class="wp-block-code"><code>_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"</code></pre>



<p class="wp-block-paragraph">Start at <code>p=none</code>. It changes nothing about how mail is handled and turns on the aggregate reports, which are the only view you will ever get of who is sending as your domain. Read them for a few weeks, find the legitimate senders you forgot about, fix their alignment, then move to <code>p=quarantine</code> and eventually <code>p=reject</code>.</p>



<p class="wp-block-paragraph">Do not skip straight to <code>p=reject</code> on a domain you have not observed. You will reject your own invoicing system.</p>



<p class="wp-block-paragraph">The specification itself was updated. RFC 7489, the original DMARC document from 2015, has been obsoleted by RFC 9989 together with RFC 9990 for aggregate reporting and RFC 9991 for failure reporting, and DMARC has moved onto the IETF standards track. Records still begin with <code>v=DMARC1</code> and nothing you have published stops working, so this is not an emergency. Two changes are worth knowing:</p>



<ul class="wp-block-list">
<li>The <code>pct</code> tag is gone, along with <code>rf</code> and <code>ri</code>. Percentage-based staged rollout was implemented inconsistently by receivers and has been replaced by a simple <code>t</code> testing flag. If your record still carries a partial <code>pct</code> value from a rollout that never finished, that is worth cleaning up.</li>



<li>Organisational domain discovery no longer depends on the Public Suffix List; receivers now walk the DNS tree. Single-domain owners will not notice. If you run a lot of subdomains, publishing explicit records rather than relying on inheritance removes the ambiguity.</li>
</ul>



<p class="wp-block-paragraph">There are also new optional tags. The one worth considering is <code>np</code>, which sets a policy for non-existent subdomains so attackers cannot spoof from a subdomain you never created, without changing your main policy.</p>



<p class="wp-block-paragraph">Aggregate reports arrive as compressed XML and are unpleasant to read by hand. Feeding them to a processor is worth it; dmarcian, Postmark&#8217;s free DMARC digest and MXToolbox all do this, and Cloudflare can host the DNS side.</p>



<h3 class="wp-block-heading">Are the bulk sender rules your problem?</h3>



<p class="wp-block-paragraph">Probably not, and it is worth saying so plainly because a lot of writing on this topic implies otherwise. Google&#8217;s stricter bulk sender requirements apply to domains sending roughly 5,000 or more messages a day to personal Gmail accounts. A typical WordPress business site sends a few dozen.</p>



<p class="wp-block-paragraph">What changed for everyone is the direction of travel. Authentication that used to be a nice-to-have is now the baseline for being evaluated on your merits rather than treated as suspicious by default. If you do cross into bulk territory, the additional obligations are one-click unsubscribe on marketing mail via the <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers, and keeping spam complaints low. Google&#8217;s published guidance is to stay under 0.1% and never reach 0.3%.</p>



<h2 class="wp-block-heading">Domain strategy: keep resets away from newsletters</h2>



<p class="wp-block-paragraph">One structural decision saves more grief than any single record. Separate your transactional mail from your marketing mail at the domain level.</p>



<p class="wp-block-paragraph">Send password resets and order confirmations from something like <code>mail.example.com</code>, and send campaigns from <code>news.example.com</code>. Each subdomain gets its own SPF record, which keeps you comfortably inside the lookup budget. More importantly, reputation is largely tracked per sending domain. When a campaign goes badly and complaints spike, the damage stays contained. Your password resets keep landing.</p>



<p class="wp-block-paragraph">The trade-off is real: more DNS records to maintain, more DMARC reports to read, and a From address that is slightly less clean. On a site where a lost order confirmation costs actual revenue, that is a trade worth making.</p>



<h2 class="wp-block-heading">Troubleshooting: a working order</h2>



<ol class="wp-block-list">
<li><strong>Does the message leave?</strong> Hook <code>wp_mail_failed</code> and send via WP-CLI. A logged error here means credentials, ports or a plugin, not DNS.</li>



<li><strong>Can the box reach the relay?</strong> <code>openssl s_client -starttls smtp</code> against the submission port. A timeout means outbound filtering.</li>



<li><strong>What does the receiver say?</strong> Send to a mailbox you control, open the raw source, read <code>Authentication-Results</code>. This single header settles most arguments.</li>



<li><strong>Is the DKIM <code>d=</code> your domain?</strong> If not, you have not completed provider domain authentication. Fix that before anything else.</li>



<li><strong>Does SPF actually evaluate?</strong> Expand nested includes and count. A <code>permerror</code> looks like a pass in most naive testers.</li>



<li><strong>What do the aggregate reports show?</strong> If mail is failing for a subset of recipients only, the reports will name the sending source you forgot about.</li>
</ol>



<p class="wp-block-paragraph">If Gmail is deferring rather than rejecting, the enhanced status code in the bounce tells you which check failed. Codes in the <code>4.7.x</code> range point at authentication or alignment specifically rather than content, which narrows the search considerably.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Publishing two SPF records and assuming they merge. They do not.</li>



<li>Treating a green tick from an online record checker as proof of delivery. Those tools validate syntax, not alignment on your actual mail.</li>



<li>Jumping to <code>p=reject</code> before reading a single aggregate report.</li>



<li>Setting a From address on a domain you do not control, such as a customer&#8217;s own address on a contact form. Use your domain in From and put theirs in Reply-To.</li>



<li>Adding a new SaaS <code>include</code> without re-counting the lookup budget.</li>



<li>Leaving a stale <code>include</code> for a service you stopped using two years ago, which is both a wasted lookup and a live authorisation.</li>



<li>Configuring an SMTP plugin and never testing what happens when the provider is down. Mail queued nowhere is mail lost.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Route WordPress mail through a real provider, and prefer the HTTP API transport when the host filters SMTP ports.</li>



<li>Complete provider domain authentication properly so DKIM signs as your domain. This is the single highest-value action on the list.</li>



<li>Publish DMARC at <code>p=none</code> with <code>rua</code> on day one, even before you intend to enforce. The reports are the visibility you are missing.</li>



<li>Keep SPF under the ten lookup budget with headroom for the next service someone adds.</li>



<li>Split transactional and marketing sending onto separate subdomains.</li>



<li>Enable email logging in your SMTP plugin so you have a record of what was attempted and when.</li>



<li>Monitor DKIM key expiry and provider selector changes the same way you monitor TLS certificates. Both fail silently and both fail completely.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Do I need an SMTP plugin if my host already sends mail?</h3>



<p class="wp-block-paragraph">Check what the host&#8217;s relay authenticates as. If it signs with the host&#8217;s shared domain rather than yours, DKIM will not align with your From address and DMARC will fail even though mail is technically going out. If it authenticates as your domain, you are fine without a plugin.</p>



<h3 class="wp-block-heading">Is SPF enough on its own?</h3>



<p class="wp-block-paragraph">No. SPF breaks on forwarding, because the forwarding server is not in your record, and it authenticates the envelope sender rather than the visible From address. DKIM survives forwarding and gives you a second, independent path to alignment. Publish both.</p>



<h3 class="wp-block-heading">Why do my emails reach Gmail but not Outlook, or the reverse?</h3>



<p class="wp-block-paragraph">Receivers weight signals differently and each maintains its own reputation view of your sending IP and domain. Divergent behaviour usually means your authentication is borderline rather than solid, so one receiver&#8217;s threshold catches it and another&#8217;s does not. Fix alignment rather than chasing individual receivers.</p>



<h3 class="wp-block-heading">How long until DNS changes take effect?</h3>



<p class="wp-block-paragraph">As long as the TTL on the record, plus whatever the resolver in front of you decides to cache. Lower the TTL before you plan changes, not after. Verify with <code>dig</code> against an authoritative nameserver rather than trusting a browser-based checker.</p>



<h3 class="wp-block-heading">Does DMARC stop people spoofing my domain?</h3>



<p class="wp-block-paragraph">At <code>p=reject</code> it stops exact-domain spoofing at receivers that honour the policy, which is most large ones. It does nothing about lookalike domains, display-name spoofing, or a compromised mailbox sending genuinely authenticated mail. It is one control, not a complete answer to phishing.</p>



<h3 class="wp-block-heading">Should I update my DMARC record for the new RFCs?</h3>



<p class="wp-block-paragraph">Nothing breaks if you do not. At your next DNS edit, remove <code>pct</code>, <code>rf</code> and <code>ri</code> if present, and consider adding <code>np</code> if you want to close the non-existent subdomain gap. Leave the new tags unset otherwise, because the defaults are sensible.</p>



<h3 class="wp-block-heading">Why did deliverability get worse after I moved hosts?</h3>



<p class="wp-block-paragraph">Usually because the old host had a relay configured that the new one does not, so PHP <code>mail()</code> is now sending direct from an unwarmed IP with no authentication. It is also common for a migration to move the site but not the DNS records the old provider had added. Re-verify SPF, DKIM selectors and the return path after any move.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">If you remember one thing about WordPress email deliverability, remember that authentication and alignment are different questions. SPF passing and DKIM verifying does not mean DMARC passes. The receiver is asking whether the domain your recipient can see is the domain that proved itself, and on a default WordPress plus transactional provider setup, the honest answer is often no.</p>



<p class="wp-block-paragraph">Get the transport off PHP <code>mail()</code>, complete your provider&#8217;s domain authentication so <code>d=</code> is your own domain, keep SPF inside its lookup budget, and publish DMARC at <code>p=none</code> so you can finally see what is happening. Then read the reports for a month before you enforce anything.</p>



<p class="wp-block-paragraph">The alternative is closing the same ticket every week and never learning why.</p>



<h2 class="wp-block-heading">Need someone to fix this properly?</h2>



<p class="wp-block-paragraph">Mail problems are unusually well suited to outside help, because the failure is almost always in a place the site owner cannot see. This is work I take on regularly:</p>



<ul class="wp-block-list">
<li>Auditing an existing setup end to end and telling you exactly which of the four failure families you are in</li>



<li>Rebuilding SPF records that have drifted past the ten lookup limit, without breaking a live sender</li>



<li>Completing DKIM and return-path domain authentication with your provider so alignment actually holds</li>



<li>Taking a domain from <code>p=none</code> to enforcement safely, using the aggregate reports rather than guesswork</li>



<li>Splitting transactional and marketing sending onto separate subdomains with their own records and reputation</li>



<li>Migrating WordPress mail off PHP <code>mail()</code> onto a provider, with logging and failure alerting wired up</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me a raw message source with the full headers, or your current SPF and DMARC records, and I will tell you what I see.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/wordpress-email-deliverability-spf-dkim-dmarc/">Why Your WordPress Emails Vanish: SMTP, SPF, DKIM and DMARC</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>WP-CLI Commands That Save Hours (And the One That Bites Back)</title>
		<link>https://john-nessime.com/blog/technical-guides/wp-cli-commands-that-save-hours/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 09 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[Command Line]]></category>
		<category><![CDATA[search-replace]]></category>
		<category><![CDATA[SSH]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[WordPress Database]]></category>
		<category><![CDATA[WordPress Maintenance]]></category>
		<category><![CDATA[WordPress Migration]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<category><![CDATA[WP-Cron]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=171</guid>

					<description><![CDATA[<p>The dashboard is fine for editing a post. It is a terrible tool for anything touching a thousand rows, twelve plugins or two servers. Here are the WP-CLI commands that save hours on migrations, plugin conflicts, stalled cron and integrity checks, grouped by the job that eats the time, with the failure modes each one hides.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/wp-cli-commands-that-save-hours/">WP-CLI Commands That Save Hours (And the One That Bites Back)</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket said &#8220;staging is approved, push it live.&#8221; Copy the files, import the database, change the two URL fields in Settings, done. The homepage loads. Then the slider is empty, a third of the images 404, and the page builder renders a grey box where a section used to be.</p>



<p class="wp-block-paragraph">That is the moment most people properly meet the command line, usually at the worst possible time. The dashboard is fine for editing a post. It is a bad tool for anything that touches a thousand rows, twelve plugins, or two servers at once.</p>



<p class="wp-block-paragraph">This is a working list of the WP-CLI commands that save hours, grouped by the job that eats the hours rather than by command name. Each section leads with the failure mode, because with a couple of these the command finishes, prints <code>Success</code>, and leaves you with a subtly broken database that nobody notices for a week.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The migration hour: search-replace, and the one that bites back</h2>



<p class="wp-block-paragraph">Changing a domain by running SQL against <code>wp_posts</code> is how sites break. WordPress stores a great deal of configuration as PHP serialized arrays, and serialized strings carry their own byte length. Change <code>https://old.example.com</code> to <code>https://new.example.org</code> with a plain <code>UPDATE ... REPLACE()</code> and the length prefix no longer matches the string. PHP refuses to unserialize the value, returns <code>false</code>, and the widget or page-builder section silently renders nothing.</p>



<p class="wp-block-paragraph">The whole point of <code>wp search-replace</code> is that it unserializes, replaces, and reserializes, so the lengths stay correct. Run it in dry-run mode first, every single time:</p>



<pre class="wp-block-code"><code>wp search-replace 'https://old.example.com' 'https://new.example.org' 
  --all-tables-with-prefix 
  --skip-columns=guid 
  --precise 
  --report-changed-only 
  --dry-run</code></pre>



<p class="wp-block-paragraph">What each flag is actually doing:</p>



<ul class="wp-block-list">
<li><code>--dry-run</code> runs the whole operation and prints the report without writing. The report has a &#8220;Type&#8221; column: <code>PHP</code> means the value was serialized and handled properly, <code>SQL</code> means a plain string replacement. If you expected hundreds of changes and see zero, your search string is wrong, not the tool.</li>

<li><code>--all-tables-with-prefix</code> widens the scope to every table sharing your prefix, not just the tables registered with <code>$wpdb</code>. Plugins that create their own tables live here. <code>--all-tables</code> goes wider still and will happily rewrite tables belonging to a completely different application sharing the database, so reach for the prefix version first.</li>

<li><code>--skip-columns=guid</code> is the one people leave off. The <code>guid</code> column is a permanent identifier for feed readers, not a URL to be followed. Rewriting it makes every historical post look brand new to anything that has subscribed to your feed.</li>

<li><code>--precise</code> forces PHP-side serialization handling on every column instead of letting MySQL do the simple cases. It is slower. On a large database it is noticeably slower. It is also the difference between &#8220;probably fine&#8221; and &#8220;definitely correct&#8221;.</li>

<li><code>--report-changed-only</code> trims the report to tables that actually changed, which makes the output readable instead of a wall of zeros.</li>
</ul>



<p class="wp-block-paragraph">Read the dry-run output. When the numbers look right, run the identical command without <code>--dry-run</code>.</p>



<h3 class="wp-block-heading">The step everyone forgets</h3>



<p class="wp-block-paragraph">You changed the database. Every cache layer still holds the old values. On a site with Redis or Memcached behind a persistent object cache drop-in, the old URLs will keep being served from memory and you will spend twenty minutes convinced the replacement failed.</p>



<pre class="wp-block-code"><code>wp cache flush
wp transient delete --expired
wp rewrite flush</code></pre>



<p class="wp-block-paragraph">Be aware that on multisite with a shared persistent object cache, <code>wp cache flush</code> can clear entries for every site on the network, not just the one you are working on. Then purge the page cache and the CDN through their own controls. If you are on Cloudflare, the database change means nothing until the edge cache is purged too.</p>



<p class="wp-block-paragraph">Finally, re-run the dry-run. It should report zero remaining replacements. If it does not, you have a second URL format hiding somewhere: protocol-relative <code>//old.example.com</code>, or an escaped form like <code>https://old.example.com</code> inside JSON stored in an option. Run a second pass for each shape you find.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The plugin conflict hour: bisect without touching the dashboard</h2>



<p class="wp-block-paragraph">Classic scenario: something fatals, the admin is white, and the usual advice is &#8220;deactivate all plugins and switch to a default theme.&#8221; On a production site that means real downtime while you click through a list.</p>



<p class="wp-block-paragraph">WP-CLI has two global parameters that skip loading plugins and themes for the duration of a single command. Nothing is deactivated. Nothing is written. The site keeps serving visitors exactly as it was.</p>



<pre class="wp-block-code"><code># Does WP-CLI work at all with nothing loaded?
wp --skip-plugins --skip-themes option get siteurl

# Is it the theme?
wp --skip-themes option get siteurl

# Skip one specific plugin
wp --skip-plugins=akismet option get siteurl</code></pre>



<p class="wp-block-paragraph">If the first command works and the unmodified one does not, the fault is in your plugins or theme, not in WP-CLI or WordPress core. From there you can walk the active list one at a time. This one-liner from the WP-CLI handbook does the walk for you:</p>



<pre class="wp-block-code"><code>wp plugin list --field=name --status=active --skip-plugins 
  | xargs -n1 -I % wp --skip-plugins=% plugin get % --field=name</code></pre>



<p class="wp-block-paragraph">The inner <code>--skip-plugins</code> on the listing command matters. Without it, a plugin broken badly enough to crash the bootstrap will also crash the command that is trying to build the list, and you get nothing.</p>



<p class="wp-block-paragraph">The honest limitation: skipping a plugin at the CLI does not reproduce browser-side behaviour. If the bug only appears for logged-in users on a specific admin screen, this narrows the field but will not fully confirm the culprit. It still turns a thirty-minute click-through into about two minutes of scrolling output.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The silent-cron hour: scheduled jobs that quietly stopped</h2>



<p class="wp-block-paragraph">This is the invisible failure. Nobody opens a ticket saying &#8220;cron is broken.&#8221; They open a ticket saying backups have not run since last month, or scheduled posts are stuck in the future, or order emails stopped going out. WP-Cron is not a scheduler. It is a queue that gets drained when someone loads a page, which means a low-traffic site drains it rarely and a fully page-cached site may never drain it at all.</p>



<pre class="wp-block-code"><code># Can the site reach its own wp-cron.php?
wp cron test

# What is scheduled, and how overdue is it?
wp cron event list

# Drain everything that is due, right now, in this process
wp cron event run --due-now</code></pre>



<p class="wp-block-paragraph">In <code>wp cron event list</code>, the column to read is <code>next_run_relative</code>. If events are showing as long overdue, the queue is not being drained and you have found your problem.</p>



<p class="wp-block-paragraph">The fix is to stop relying on page loads. Disable the loopback trigger in <code>wp-config.php</code>, above the &#8220;stop editing&#8221; line:</p>



<pre class="wp-block-code"><code>define( 'DISABLE_WP_CRON', true );</code></pre>



<p class="wp-block-paragraph">Then add a real system cron entry for the user that owns the site files:</p>



<pre class="wp-block-code"><code>* * * * * /usr/local/bin/wp --path=/var/www/example.com cron event run --due-now --quiet</code></pre>



<p class="wp-block-paragraph">Why the CLI form and not a <code>curl</code> of <code>wp-cron.php</code>: <code>wp cron event run</code> executes the events in the current PHP process. No HTTP loopback is involved, so it cannot be defeated by a firewall rule, a reverse proxy, HTTP authentication on staging, or a host that rate-limits self-requests. Errors surface in the cron mail or the log you redirect to, instead of vanishing into a request nobody reads.</p>



<p class="wp-block-paragraph">One warning. Set <code>DISABLE_WP_CRON</code> and then forget the crontab entry, and you have upgraded an unreliable scheduler into one that never runs at all. Verify with <code>wp cron event list</code> a few minutes later before you close the ticket.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The bulk-edit hour: piping IDs instead of clicking pages</h2>



<p class="wp-block-paragraph">The pattern that does the real work here is <code>--format=ids</code> feeding another command. Once that clicks, most &#8220;there is no plugin for this&#8221; tasks become one line.</p>



<pre class="wp-block-code"><code># How many revisions are we carrying?
wp post list --post_type=revision --format=count

# Delete them, skipping the trash
wp post delete $(wp post list --post_type=revision --format=ids) --force

# Every administrator on the site, in one table
wp user list --role=administrator --fields=ID,user_login,user_email

# Regenerate only the thumbnail sizes that are missing
wp media regenerate --only-missing --yes</code></pre>



<p class="wp-block-paragraph">Two things to know before you run the delete. It is irreversible, so take a database export first. And if the inner command returns nothing because there are no revisions, the outer command gets no arguments and will error rather than doing nothing quietly. That is a harmless failure, but it looks alarming the first time.</p>



<p class="wp-block-paragraph">Revisions come back unless you cap them. Adding <code>define( 'WP_POST_REVISIONS', 5 );</code> to <code>wp-config.php</code> keeps enough history to recover from a bad edit without letting <code>wp_posts</code> grow without limit.</p>



<p class="wp-block-paragraph">For anything you want to script rather than read, add <code>--format=json</code> and pipe it into <code>jq</code>. Every list command in WP-CLI supports it, which makes the whole tool usable as a data source for monitoring and reporting rather than just an admin replacement.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The &#8220;is this thing compromised?&#8221; hour: checksums</h2>



<p class="wp-block-paragraph">A site is behaving oddly, there is a suspicious redirect, and the first instinct is to install a security scanner. Before that, run the two commands that compare what is on disk against the official hashes on WordPress.org.</p>



<pre class="wp-block-code"><code>wp core verify-checksums
wp plugin verify-checksums --all</code></pre>



<p class="wp-block-paragraph">These download the published checksums for your exact version and locale and report every core or plugin file that has been modified, plus files that should not exist at all. The core command deliberately avoids loading WordPress, so it still works when the site itself is too broken to boot.</p>



<p class="wp-block-paragraph">Know the blind spots before you trust a clean result:</p>



<ul class="wp-block-list">
<li>Only plugins hosted on the WordPress.org directory can be verified. Premium plugins, custom code and anything from a private repository have no published source of truth and will be skipped or warned about.</li>

<li>Nothing in <code>wp-content/uploads</code> is covered. A dropped PHP file in an uploads subdirectory is a common backdoor and checksums will never see it.</li>

<li>Themes are not covered by an equivalent bundled command.</li>

<li>Getting a locale or version mismatch produces alarming warnings that are not real findings. Pass <code>--version</code> and <code>--locale</code> explicitly if the output looks wrong.</li>
</ul>



<p class="wp-block-paragraph">A clean checksum run is not proof the site is clean. A dirty one is proof it is not, which is worth a great deal in the first five minutes of an incident.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The database hour: find the bloat before you optimise anything</h2>



<p class="wp-block-paragraph">&#8220;The site is slow&#8221; is not actionable. &#8220;One table is carrying most of the database&#8221; is. Start by looking, not by running an optimiser.</p>



<pre class="wp-block-code"><code># Size per table, sorted by whatever is worst
wp db size --tables --human-readable

# Take an export before touching anything
wp db export ~/backups/example-com.sql

# Run a query and strip the ASCII table borders
wp db query 'SELECT option_value FROM wp_options WHERE option_name="home"' --skip-column-names</code></pre>



<p class="wp-block-paragraph">In practice the offenders are nearly always <code>wp_options</code> stuffed with autoloaded rows and orphaned transients, <code>wp_postmeta</code> from a plugin that never cleans up after itself, or <code>wp_posts</code> full of revisions. Autoloaded options are the sneaky one, because every row marked for autoload is loaded on every request, including requests that will never look at it.</p>



<p class="wp-block-paragraph">Two clean-up commands worth knowing, and the difference between them:</p>



<pre class="wp-block-code"><code>wp transient delete --expired
wp transient delete --all</code></pre>



<p class="wp-block-paragraph">The first removes only what has already expired and is safe to run on a schedule. The second removes everything, including live cache entries, which forces WordPress to rebuild them on the next requests. That is safe but produces a short performance dip, so it belongs in a maintenance window rather than in a cron job.</p>



<p class="wp-block-paragraph">Note that on a site with a persistent object cache drop-in installed, transients bypass the database entirely and wrap the object cache instead. If <code>wp transient delete --all</code> reports nothing, that is usually the reason and not a failure.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The setup that makes everything above faster</h2>



<p class="wp-block-paragraph">If you manage more than one site, the biggest single time saving is not a command at all. It is aliases. Put a <code>wp-cli.yml</code> in a project directory, or <code>~/.wp-cli/config.yml</code> for a global one:</p>



<pre class="wp-block-code"><code>@production:
  ssh: deploy@example.com/var/www/example.com
@staging:
  ssh: deploy@staging.example.com/var/www/staging</code></pre>



<p class="wp-block-paragraph">Now you run commands against a remote site from your own machine, without an SSH session:</p>



<pre class="wp-block-code"><code>wp @staging plugin list --status=active
wp @production cron event list</code></pre>



<p class="wp-block-paragraph">This needs WP-CLI installed on the remote host, and it needs your host to actually give you SSH. That varies more than people expect. A plain VPS from a provider like InterServer or DigitalOcean gives you root and you install what you like. Managed WordPress hosts differ: some ship WP-CLI preinstalled and hand you an SSH key immediately, others restrict which commands you can run, and a few give you no shell at all on entry-level plans. Worth checking before you build a workflow that depends on it.</p>



<p class="wp-block-paragraph">Two optional packages are worth the install if you do maintenance work regularly:</p>



<pre class="wp-block-code"><code>wp package install wp-cli/doctor-command
wp package install wp-cli/profile-command

wp doctor check --all
wp profile stage</code></pre>



<p class="wp-block-paragraph"><code>wp doctor</code> runs a set of health checks, including one for autoloaded options size, and gives you a pass or warn per check. <code>wp profile</code> breaks a request into stages so you can see where the time is going before you start guessing.</p>



<p class="wp-block-paragraph">The honest trade-off: these are separate packages, not bundled commands, and they get less attention than the core set. If you are working across client servers you do not control, installing packages everywhere is friction you may not want. On your own infrastructure they earn their place. For continuous visibility rather than point-in-time checks you still want proper monitoring, whether that is a self-hosted Prometheus and Grafana stack or a hosted uptime service.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting: when WP-CLI itself will not run</h2>



<p class="wp-block-paragraph">Before blaming the tool, run <code>wp cli info</code>. It prints the PHP binary in use, the PHP version, the loaded <code>php.ini</code>, and the WP-CLI version. Most problems are visible in that output.</p>



<ul class="wp-block-list">
<li><strong>&#8220;This does not seem to be a WordPress installation.&#8221;</strong> You are in the wrong directory, or the document root is elsewhere. Pass <code>--path=/full/path/to/wordpress</code> rather than guessing.</li>

<li><strong>Wrong PHP version.</strong> On a control panel server the shell PHP is often not the PHP the site runs on. Check the binary in <code>wp cli info</code>, and invoke the right one explicitly if they differ, for example <code>/usr/local/php83/bin/php $(which wp)</code>.</li>

<li><strong>Memory exhaustion on large operations.</strong> The CLI uses its own memory limit, not the web one. Raise it for a single command with <code>php -d memory_limit=512M $(which wp) ...</code> instead of editing configuration globally.</li>

<li><strong>A fatal error the moment you type anything.</strong> Confirm with <code>wp --skip-plugins --skip-themes cli info</code>. If that works, the fault is in site code.</li>

<li><strong>A warning about running as root.</strong> WP-CLI is telling you that files created by this command will be owned by root and the web server may not be able to write to them later. <code>--allow-root</code> silences the warning without fixing the ownership problem. Run as the site user instead, unless you are inside a container where root is the normal case.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Running <code>search-replace</code> without <code>--dry-run</code> because you have done it before and it worked.</li>

<li>Reaching for <code>--all-tables</code> by reflex when <code>--all-tables-with-prefix</code> is what you meant, on a server where several sites share one database.</li>

<li>Forgetting to flush the object cache and CDN after a database write, then re-running the replacement several times chasing a ghost.</li>

<li>Setting <code>DISABLE_WP_CRON</code> without adding the system cron entry.</li>

<li>Treating a clean <code>verify-checksums</code> result as proof that a site is not compromised.</li>

<li>Running destructive commands over SSH without <code>screen</code> or <code>tmux</code>, then losing the connection mid-write on a large table.</li>

<li>Using <code>--allow-root</code> as a habit and leaving root-owned files scattered through <code>wp-content</code>.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ol class="wp-block-list">
<li>Export the database before any command that writes. <code>wp db export</code> takes seconds and has saved more afternoons than any other command in this post.</li>

<li>Dry-run first, read the report, then repeat the command verbatim without the flag. Do not retype it.</li>

<li>Run as the site user, not root, so file ownership stays correct.</li>

<li>Use aliases in <code>wp-cli.yml</code> so you cannot run a staging command against production by muscle memory.</li>

<li>Verify after the write. Re-run the read-only version of whatever you just did and confirm it reports zero remaining work.</li>

<li>Test the restore path, not just the backup. An export you have never imported is a hope, not a backup.</li>

<li>Keep WP-CLI current with <code>wp cli update</code>, particularly before working on a site running a recent PHP release.</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is wp search-replace safe to run on a live site?</h3>



<p class="wp-block-paragraph">Safer than raw SQL, because it handles serialized data correctly. Still a write against production. Export the database first, dry-run first, and use <code>--skip-columns=guid</code>. On a large database the operation can hold the site in a slightly inconsistent state for the duration, so a quiet window helps.</p>



<h3 class="wp-block-heading">Why do my changes not show up after search-replace?</h3>



<p class="wp-block-paragraph">Almost always caching. Run <code>wp cache flush</code>, purge the page cache and the CDN, then reload. If the old value still appears in a dry-run report, you have a second URL format stored somewhere, such as a protocol-relative or escaped variant.</p>



<h3 class="wp-block-heading">Does WP-CLI need SSH access?</h3>



<p class="wp-block-paragraph">It needs shell access on a machine that can reach the WordPress files and database. That usually means SSH. Some managed hosts provide a browser terminal instead, and some restrict the available commands. On a self-managed VPS you install it yourself and nothing is restricted.</p>



<h3 class="wp-block-heading">What is the difference between &#8211;skip-plugins and deactivating a plugin?</h3>



<p class="wp-block-paragraph"><code>--skip-plugins</code> prevents the plugin from loading for that one command only. Nothing is written to the database and visitors are unaffected. Deactivating changes site state and takes the plugin offline for everyone, which is why the skip flags are the right diagnostic tool on production.</p>



<h3 class="wp-block-heading">Can I schedule WP-CLI commands with cron?</h3>



<p class="wp-block-paragraph">Yes, and that is the recommended way to run WordPress scheduled events reliably. Use the full path to the <code>wp</code> binary, pass <code>--path</code>, run as the site user, and redirect output so failures are visible rather than silent.</p>



<h3 class="wp-block-heading">Will verify-checksums detect all malware?</h3>



<p class="wp-block-paragraph">No. It compares core files and WordPress.org-hosted plugins against published hashes. It does not cover themes, premium or custom plugins, or anything in the uploads directory. Treat a failure as a definite finding and a pass as one data point among several.</p>



<h3 class="wp-block-heading">Which WP-CLI commands should I learn first?</h3>



<p class="wp-block-paragraph"><code>wp db export</code>, <code>wp search-replace --dry-run</code>, <code>wp --skip-plugins</code>, and <code>wp cron event list</code>. Those four cover backup, migration, conflict isolation and the most common silent failure on a WordPress site.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Most of the WP-CLI commands that save hours do it by turning a job you cannot see into a job you can read. <code>--dry-run</code> shows you the write before it happens. <code>wp cron event list</code> shows you a queue that was failing silently. <code>verify-checksums</code> shows you a file that changed without anyone deciding it should.</p>



<p class="wp-block-paragraph">The speed is real, but the speed is not the point. The point is that you stop guessing. If you take one habit from this post, take the dry-run: run the read-only version, read the output, then run the identical command for real. That single pattern prevents the majority of the incidents I see caused by command-line work on WordPress.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a hand with this?</h2>



<p class="wp-block-paragraph">I work on WordPress infrastructure, and command-line automation is most of what makes that maintainable. Things I can help with directly:</p>



<ul class="wp-block-list">
<li>Domain and host migrations run properly, with staged dry-runs, correct table scoping and a verified rollback before anything is written.</li>

<li>Replacing WP-Cron with real system cron across a fleet, including alerting when a job stops firing instead of finding out weeks later.</li>

<li>Writing repeatable WP-CLI maintenance scripts, with proper exit codes and logging, so they can be scheduled rather than remembered.</li>

<li>Diagnosing slow WordPress sites at the database layer, from autoloaded options and table bloat down to the query causing it.</li>

<li>Incident triage on a site suspected of being compromised, starting with integrity checks and file-level evidence rather than a scanner plugin.</li>

<li>Setting up multi-site management workflows with WP-CLI aliases, SSH keys and deployment pipelines that do not depend on anyone opening the dashboard.</li>
</ul>



<p class="wp-block-paragraph">If something specific is going wrong, send the actual output. A dry-run report, a <code>wp cli info</code> dump, a <code>wp cron event list</code> table, and I can usually tell you where the problem is before we talk about scope.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/wp-cli-commands-that-save-hours/">WP-CLI Commands That Save Hours (And the One That Bites Back)</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Managed WordPress Hosting vs VPS: Who Owns the Failure?</title>
		<link>https://john-nessime.com/blog/wordpress/managed-wordpress-hosting-vs-vps/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 08 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Hosting & Infrastructure]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Managed Hosting]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[Redis]]></category>
		<category><![CDATA[Restore Testing]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[VPS]]></category>
		<category><![CDATA[Website Migration]]></category>
		<category><![CDATA[WordPress Backup]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=165</guid>

					<description><![CDATA[<p>Managed WordPress hosting and a self-managed VPS can both serve a fast site. They differ on which failures land on you. A practical comparison covering visit metering, disallowed plugin policies, silent backup failure, patching debt, and a cost model that survives contact with reality.</p>
<p>The post <a href="https://john-nessime.com/blog/wordpress/managed-wordpress-hosting-vs-vps/">Managed WordPress Hosting vs VPS: Who Owns the Failure?</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A colleague pinged me about a site where the caching plugin had stopped existing. Not deactivated. Gone from the plugin list, gone from the filesystem. Nobody on the team had touched it.</p>



<p class="wp-block-paragraph">The host had removed it. It was on their disallowed list, a routine scan found it, and the platform did exactly what its documentation said it would do. Everyone had agreed to that when they signed up. Nobody had read it.</p>



<p class="wp-block-paragraph">That incident is the whole managed WordPress hosting vs VPS argument in miniature, and it has almost nothing to do with page speed. Both models can serve a fast WordPress site. They differ on which failures land on you, which land on someone else, and which ones stay invisible until the worst possible moment.</p>



<p class="wp-block-paragraph">This post compares the two the way I&#8217;d compare them for a client: an honest profile of each, the costs that never appear on the invoice, a procedure for modelling the real number, and the decision rules I actually use. No benchmark charts, because your theme and your plugin count will dominate anything the host does.</p>



<h2 class="wp-block-heading">The real question: who owns the failure at 2am</h2>



<p class="wp-block-paragraph">Most comparisons frame this as control versus convenience. True, but useless, because it doesn&#8217;t tell you what to buy. The framing that predicts regret is: when something breaks, whose problem is it, and how fast can they fix it?</p>



<p class="wp-block-paragraph">Split the failure surface into layers and the answer gets concrete:</p>



<ul class="wp-block-list">
<li><strong>Hardware and hypervisor.</strong> Theirs in both models. Your recovery time is still yours.</li>

<li><strong>OS, web server, PHP, database.</strong> Theirs on managed hosting, yours on an unmanaged VPS. This is the big split.</li>

<li><strong>Backups and restores.</strong> Theirs on managed, and tested, because their business depends on it. Yours on a VPS, and almost nobody tests them.</li>

<li><strong>Core, themes, plugins.</strong> Mostly yours either way. Some hosts auto-update, but the compatibility fallout is still yours.</li>

<li><strong>Your code and your content.</strong> Always yours. No host takes this on.</li>
</ul>



<p class="wp-block-paragraph">The bottom two rows never move. A managed platform will not make your bloated page builder fast, and it will not stop a vulnerable plugin from being exploited. It moves the middle three rows off your plate and charges you in money and in flexibility.</p>



<h2 class="wp-block-heading">Managed WordPress hosting, assessed honestly</h2>



<h3 class="wp-block-heading">Where it genuinely wins</h3>



<p class="wp-block-paragraph">The strongest argument isn&#8217;t performance. It&#8217;s that the boring, high-consequence work gets done by people whose job it is, on a schedule, whether or not you remember.</p>



<ul class="wp-block-list">
<li><strong>Restores that work.</strong> One-click recovery from a backup the platform verifies. That&#8217;s the difference between a bad hour and a bad week.</li>

<li><strong>Server-level page caching, already tuned.</strong> WP Engine, Kinsta, Pressable and Flywheel run full-page caching in front of PHP. It&#8217;s why they ban plugins that try to do the same thing badly.</li>

<li><strong>Staging that isn&#8217;t a science project.</strong> Clone, test, push back. On a VPS that&#8217;s yours to build, and a half-built staging setup is worse than none.</li>

<li><strong>Support that knows WordPress.</strong> A generic VPS provider&#8217;s ticket ends at &#8220;the server is up&#8221;. A WordPress host will look at your slow query log.</li>

<li><strong>Edge protection before PHP loads.</strong> WAF and bot filtering at the infrastructure layer, the only layer where they meaningfully help. A plugin firewall runs after the request has already cost you a PHP worker.</li>
</ul>



<p class="wp-block-paragraph">If nobody on your team wants to own a Linux box, that list is decisive. The rest of this section is about what you&#8217;re trading away.</p>



<h3 class="wp-block-heading">Visit metering is a billing model, not a traffic report</h3>



<p class="wp-block-paragraph">This is the cost that bites hardest, because it doesn&#8217;t look like a technical constraint until the invoice arrives.</p>



<p class="wp-block-paragraph">Most managed plans meter &#8220;visits&#8221; rather than bandwidth or CPU, and the definition is narrower than it sounds. <a href="https://wpengine.com/support/count-visits/" target="_blank" rel="noreferrer noopener">WP Engine defines a billable visit</a> as one unique IP address logged per day, with static assets like images, CSS and JavaScript excluded, and known and suspected bot user agents filtered out. <a href="https://kinsta.com/docs/billing/wordpress-hosting-plans/overages/" target="_blank" rel="noreferrer noopener">Kinsta counts</a> the sum of unique IPs seen within each 24-hour period across the plan, and also sells bandwidth-metered plans as an alternative. Flywheel filters IPs belonging to known bots, spammers and attackers before counting.</p>



<p class="wp-block-paragraph">Three consequences follow, and they&#8217;re the ones people miss:</p>



<ul class="wp-block-list">
<li><strong>Your host&#8217;s number will not match Google Analytics.</strong> Analytics counts sessions from browsers that ran its JavaScript. Your host counts IPs that hit the server. Arguing about the gap is wasted effort.</li>

<li><strong>Bot filtering is best-effort.</strong> Hosts filter what they can identify. A scraper with a browser-shaped user agent and rotating IPs looks, to the meter, like a crowd of new visitors.</li>

<li><strong>Overages are charged per block of excess visits.</strong> Rates change, so check the current figure rather than trusting any article, including this one. What matters is the shape: a viral post or a bad crawl month produces a bill you didn&#8217;t budget for.</li>
</ul>



<p class="wp-block-paragraph">A VPS has the opposite failure mode. Bad traffic costs you CPU and RAM, not money, right up until it exhausts them and the site falls over. One model converts load into a bill, the other converts it into an outage. Pick the one you&#8217;d rather explain to whoever owns the site.</p>



<h3 class="wp-block-heading">The plugin policy is a real constraint</h3>



<p class="wp-block-paragraph">Managed hosts publish disallowed plugin lists and they enforce them. WP Engine documents that disallowed plugins are found by periodic scans of the site&#8217;s filesystem, the owner is notified, and the plugin is removed. That&#8217;s not a threat, it&#8217;s the operating model, and it&#8217;s why the incident I opened with was nobody&#8217;s fault.</p>



<p class="wp-block-paragraph">The banned categories are consistent across vendors, and the reasoning holds up in each case:</p>



<ul class="wp-block-list">
<li><strong>Caching plugins.</strong> They fight the platform&#8217;s own page cache and produce stale output that support then has to debug.</li>

<li><strong>Backup plugins.</strong> They write large archives onto the same disk the site runs on, which is a useless backup if the disk is what fails.</li>

<li><strong>Database optimisation plugins.</strong> Long-running write-heavy queries on shared infrastructure affect neighbours.</li>

<li><strong>Some security plugins, or specific features within them.</strong> Filesystem-based rule storage and high-volume traffic logging are the usual triggers. Policies vary a lot here.</li>

<li><strong>Plugins that send mail directly.</strong> Platforms push you to a transactional provider instead, for deliverability reasons that are genuinely in your interest.</li>
</ul>



<p class="wp-block-paragraph">None of that is unreasonable. The problem is when a plugin your business depends on lands in one of those buckets, or when a host adds something to the list later. Before migrating, read the current disallowed list for that specific host and diff it against your live active plugins. Ten minutes, and it&#8217;s the highest-value thing you can do in a hosting evaluation.</p>



<h3 class="wp-block-heading">You get one shape of application</h3>



<p class="wp-block-paragraph">A managed WordPress platform runs WordPress. If your project also needs a Node service, a Python worker, a queue, or a self-hosted analytics instance, that&#8217;s a second bill somewhere else. On a VPS those things are free in cash terms because you already own the machine. They cost you memory and attention instead. Whether that&#8217;s a good trade depends on whether you were going to need them anyway.</p>



<h2 class="wp-block-heading">Running WordPress on your own VPS, assessed honestly</h2>



<h3 class="wp-block-heading">Where it genuinely wins</h3>



<ul class="wp-block-list">
<li><strong>Resources per unit of money.</strong> Hetzner, InterServer, DigitalOcean and Vultr will sell you more CPU and RAM than an entry managed plan. Whether you can use it is a separate question.</li>

<li><strong>Many sites, one bill.</strong> This is where the economics genuinely flip. Ten small sites on one adequate VPS is one server to patch. Ten managed plans is ten invoices.</li>

<li><strong>No metering surprises.</strong> Traffic spikes hit your load average, not your credit card.</li>

<li><strong>You choose the stack.</strong> Nginx or Apache, PHP-FPM pool sizing, Redis for the object cache, your own MariaDB tuning and firewall rules. If the platform&#8217;s opinions were what limited you, this is the fix.</li>

<li><strong>No lock-in.</strong> A tarball and a database dump move anywhere. Platform-specific caching behaviour, redirect rules and deployment workflows do not.</li>
</ul>



<h3 class="wp-block-heading">The failure that stays invisible: the backup you never restored</h3>



<p class="wp-block-paragraph">Here&#8217;s the one that actually ends projects. Backups on a self-managed VPS fail silently. The cron entry runs, the exit code goes nowhere, the destination bucket quietly rejects writes after a credential rotation, and nothing tells you. You find out the day you need it, which is also the day you have no other option.</p>



<p class="wp-block-paragraph">The fix isn&#8217;t a better tool. Restic and BorgBackup are both excellent and neither will save you. The fix is a restore drill on a schedule, into a scratch directory, where you verify the data is actually there.</p>



<pre class="wp-block-code"><code># Restore the newest snapshot into a scratch path, never over the live site
restic -r /srv/backups/wp restore latest --target /tmp/restore-drill

# Confirm the dump is real and not a zero-byte file or a truncated
# write from a run that died halfway through
ls -lh /tmp/restore-drill/db/
head -n 20 /tmp/restore-drill/db/site.sql

# Load into a throwaway database and count what came back.
# If wp_posts is empty, your backups have been failing for weeks.
mysql -u drill -p drill_db &lt; /tmp/restore-drill/db/site.sql
mysql -u drill -p -e "SELECT COUNT(*) FROM drill_db.wp_posts;"</code></pre>



<p class="wp-block-paragraph">Adjust the repository path and database names to your own. The shape is the point: restore somewhere harmless, then check a row count that would be non-zero on a healthy site. A backup you haven&#8217;t restored is a hypothesis, not a backup. Uptime checks from something like UptimeRobot tell you the site is down; they tell you nothing about whether you can get it back.</p>



<h3 class="wp-block-heading">Patching debt compounds quietly</h3>



<p class="wp-block-paragraph">The second invisible cost is the gap between &#8220;updates are available&#8221; and &#8220;updates are applied and the affected services have actually restarted&#8221;. Upgrading a package without restarting the process leaves the old, vulnerable code resident in memory. That&#8217;s why security scanners and package managers disagree so often.</p>



<pre class="wp-block-code"><code># RHEL family (AlmaLinux, Rocky, CentOS Stream)
# What security updates are outstanding?
sudo dnf updateinfo list security

# After applying them: does anything still need a restart?
# Exit 0 means no reboot required, exit 1 means reboot required.
sudo dnf needs-restarting -r

# Debian and Ubuntu: what would unattended-upgrades actually do?
sudo unattended-upgrade --dry-run --debug</code></pre>



<p class="wp-block-paragraph">Run those on any VPS you inherited from someone else. The output is usually educational. Then there&#8217;s the WordPress layer, which stays yours regardless of hosting model:</p>



<pre class="wp-block-code"><code># Do the core files match the official checksums for this version?
# A mismatch means a modified core file or an active compromise.
wp core verify-checksums

# How many plugins are behind?
wp plugin list --update=available --format=count

# Which tables are eating the disk? Usually wp_options, wp_postmeta,
# or a logging plugin nobody remembers installing.
wp db size --tables --format=table</code></pre>



<h2 class="wp-block-heading">Model the cost before you argue about it</h2>



<p class="wp-block-paragraph">Sticker price comparisons are how people talk themselves into the wrong answer in both directions. This procedure produces a number you can defend.</p>



<ol class="wp-block-list">
<li><strong>Count your sites.</strong> One tilts toward managed. Five or more tilts toward a VPS, because admin time is close to fixed and plan fees are per-site.</li>

<li><strong>Estimate your metered visit count from your own logs.</strong> If you&#8217;re already on a server you control, you can approximate what a metered host would bill you before signing anything.</li>

<li><strong>Price the VPS honestly.</strong> Instance plus block storage plus offsite backup destination plus any control panel licence. The advertised instance price is rarely the whole line.</li>

<li><strong>Put a rate on your own hours.</strong> Whatever you&#8217;d bill a client, or pay someone else. Zero is not an honest number.</li>

<li><strong>Add the one-off build.</strong> Standing up a hardened WordPress VPS with TLS, firewall, object cache, tested backups and log rotation is a solid chunk of focused work the first time. Amortise it over twelve months.</li>

<li><strong>Add your recovery cost.</strong> Hours to rebuild from scratch, times your rate, times how often you honestly think it&#8217;ll happen. This term is what makes managed hosting look cheap for a single revenue-generating site.</li>
</ol>



<p class="wp-block-paragraph">Step two is worth doing properly, because it&#8217;s the number nobody has. On Nginx with the standard combined log format, this gets you close:</p>



<pre class="wp-block-code"><code># Unique client IPs in the log. A rough upper bound on metered "visits".
awk '{print $1}' /var/log/nginx/access.log | sort -u | wc -l

# Closer to how hosts count: ignore static assets, since most platforms
# exclude images, CSS and JS from billable visits.
awk '$7 !~ /.(css|js|png|jpg|jpeg|gif|svg|webp|woff2?|ico)$/ {print $1}' 
  /var/log/nginx/access.log | sort -u | wc -l

# What is actually hitting you? User agents by request count.
# If the top entries are crawlers, that is your overage risk.
awk -F'"' '{print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20</code></pre>



<p class="wp-block-paragraph">Field positions assume the combined log format; adjust the column numbers if you&#8217;ve customised <code>log_format</code>. The third command is the interesting one. If crawlers dominate your traffic, visit-metered hosting will charge you for whatever the host&#8217;s filtering misses, and that&#8217;s a risk you can quantify before signing up rather than after.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The middle ground worth looking at first</h2>



<p class="wp-block-paragraph">This gets presented as two options. There are four, and the middle two get skipped far too often.</p>



<ul class="wp-block-list">
<li><strong>Managed VPS.</strong> InterServer, Liquid Web and others sell VPS plans where the provider handles OS patching, firewall and backups while you keep root. Read the scope: &#8220;managed&#8221; often stops at the operating system and leaves the WordPress stack to you.</li>

<li><strong>Control panel on your own VPS.</strong> DirectAdmin, cPanel or CyberPanel gives you WordPress-aware tooling, staging and one-click TLS on infrastructure you still own. You pay a licence and give up some configuration purity.</li>

<li><strong>Server management platforms.</strong> Cloudways, RunCloud, GridPane and SpinupWP provision and manage a stack on a VPS you rent from a provider of your choice. Managed-style workflows without the per-visit meter.</li>

<li><strong>A CDN in front of anything.</strong> Putting Cloudflare or a similar edge ahead of a modest VPS absorbs a large share of the traffic that would otherwise push you onto a bigger plan. Cheapest performance win available to either model, and frequently the actual answer to &#8220;we need better hosting&#8221;.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;re leaning toward a VPS mainly because managed pricing looks steep, look at options two and three before committing to a bare Linux box.</p>



<h2 class="wp-block-heading">How I&#8217;d decide between managed WordPress hosting vs VPS</h2>



<p class="wp-block-paragraph">These are the rules I apply, in order. The first match usually settles it.</p>



<ul class="wp-block-list">
<li><strong>Nobody on the team can or wants to run Linux.</strong> Managed, and it isn&#8217;t close. An unpatched VPS is worse than the most restrictive managed plan.</li>

<li><strong>One site, and it generates revenue.</strong> Managed. The recovery term dominates everything else in the cost model.</li>

<li><strong>Five or more sites and someone competent to run them.</strong> VPS, or a management platform on top of one.</li>

<li><strong>You need services WordPress isn&#8217;t.</strong> VPS. Don&#8217;t pay for two platforms to avoid learning one.</li>

<li><strong>A plugin you can&#8217;t remove is on the host&#8217;s disallowed list.</strong> VPS, or a different managed host. Do not migrate hoping for an exception.</li>

<li><strong>Traffic is spiky and crawler-heavy.</strong> Lean VPS, or pick a bandwidth-metered plan over a visit-metered one. Metering punishes exactly this traffic shape.</li>

<li><strong>You&#8217;re mostly annoyed at the current bill.</strong> Neither, yet. Put a CDN in front, cut the plugin count, fix the object cache. Hosting is often not the bottleneck.</li>
</ul>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<ul class="wp-block-list">
<li><strong>&#8220;A VPS is faster because you get dedicated resources.&#8221;</strong> Only if it&#8217;s configured well. A default LEMP install with no object cache and an untuned PHP-FPM pool loses to a managed platform&#8217;s edge cache every time.</li>

<li><strong>&#8220;Managed hosting handles security, so I&#8217;m covered.&#8221;</strong> It handles infrastructure security. The most common route into a WordPress site is a vulnerable plugin, and that&#8217;s yours in both models.</li>

<li><strong>&#8220;I&#8217;ll just move if I outgrow it.&#8221;</strong> Migration cost is real and grows with the site. Redirect rules, caching behaviour, deployment workflow and email configuration all need rebuilding.</li>

<li><strong>&#8220;The VPS is only a few dollars a month.&#8221;</strong> The instance is. Offsite backup storage, monitoring and your own hours are not, and they&#8217;re most of the number.</li>

<li><strong>&#8220;Managed hosts back everything up, so I don&#8217;t need my own backups.&#8221;</strong> Retention windows are finite and account access can be lost. Keep an independent copy the host doesn&#8217;t control. This applies to both models.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is a VPS actually cheaper than managed WordPress hosting?</h3>



<p class="wp-block-paragraph">For one site, usually not once you price your own time honestly. For several sites, usually yes, and the gap widens with each site you add. The crossover sits somewhere around three to five sites for most people, depending on what your hours are worth and how much the sites change.</p>



<h3 class="wp-block-heading">Why does my managed host report more visitors than Google Analytics?</h3>



<p class="wp-block-paragraph">They measure different things. Analytics counts browser sessions that executed its JavaScript, so it misses anything blocking scripts and misses non-browser clients entirely. Your host counts requests reaching the server, deduplicated by IP per day. Bot filtering closes some of the gap but never all of it. The two numbers are not supposed to match.</p>



<h3 class="wp-block-heading">Can I run other applications alongside WordPress on managed hosting?</h3>



<p class="wp-block-paragraph">Generally no. Managed WordPress platforms run WordPress and nothing else. A background worker, a separate API, a queue or a self-hosted analytics instance needs a VPS or a second platform. Factor that second bill into the comparison.</p>



<h3 class="wp-block-heading">What happens if I install a disallowed plugin?</h3>



<p class="wp-block-paragraph">It depends on the host, but the documented pattern at WP Engine is that periodic filesystem scans detect it, you get notified, and the plugin is removed. Check the specific host&#8217;s current policy and enforcement mechanism before migrating, and check it against your live plugin list rather than what you assume is installed.</p>



<h3 class="wp-block-heading">Do I still need a caching plugin on a VPS?</h3>



<p class="wp-block-paragraph">You need caching. Whether it&#8217;s a plugin is a design choice. The strongest setup is full-page caching at the web server or CDN layer plus a persistent object cache in Redis or Memcached, which handles the repeated database queries page caching can&#8217;t touch. A page cache alone leaves logged-in traffic and admin requests hitting the database on every load.</p>



<h3 class="wp-block-heading">Is managed VPS hosting a real middle ground or just marketing?</h3>



<p class="wp-block-paragraph">It&#8217;s real, but the scope varies enormously and the word isn&#8217;t standardised. Ask specifically: who applies OS security patches, who restarts services afterwards, who owns backups and where they live, whether restores are tested, and whether the WordPress stack is in scope or the coverage stops at the operating system. Get the answers in writing before comparing prices.</p>



<h3 class="wp-block-heading">How hard is migrating from managed hosting to a VPS?</h3>



<p class="wp-block-paragraph">Moving files and the database is the easy part. The work is everything the platform did invisibly: redirect rules living in the host&#8217;s dashboard, cache purging behaviour, transactional email, TLS renewal, and any deployment workflow tied to the platform. Plan a parallel run with low DNS TTLs rather than a cutover, and keep the old plan alive until the new one has survived a full traffic cycle.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Managed WordPress hosting vs VPS isn&#8217;t a performance question, and it isn&#8217;t really a price question either. It&#8217;s a question about which failures you&#8217;re equipped to own.</p>



<p class="wp-block-paragraph">Managed hosting converts operational risk into a predictable bill and a set of constraints you have to live inside. A VPS converts that bill into flexibility plus a standing obligation to do the boring work: patch, restart, and prove your restores actually restore. Both are defensible. What isn&#8217;t defensible is buying a VPS because it looked cheap and then skipping the work, which is the most common failure in this whole category and the one that ends with a rebuild from a backup nobody ever tested.</p>



<p class="wp-block-paragraph">Run the restore drill. Whatever you choose.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second opinion on your hosting decision?</h2>



<p class="wp-block-paragraph">Most of the hosting work I get asked for is one of these. If it looks like your situation, I can help:</p>



<ul class="wp-block-list">
<li><strong>Cost modelling before you commit.</strong> Working out from your own access logs what a visit-metered plan would actually bill you, against a realistically priced VPS including backup storage and hours.</li>

<li><strong>Migration in either direction.</strong> Managed to VPS or VPS to managed, planned as a parallel run with DNS TTL staging so there&#8217;s no blind cutover.</li>

<li><strong>Building the VPS properly the first time.</strong> Nginx or Apache with PHP-FPM sized to the actual RAM, Redis object cache, TLS with automated renewal, firewall and Fail2ban, log rotation that doesn&#8217;t fill the disk.</li>

<li><strong>Backups you&#8217;ve actually restored.</strong> Offsite destination, retention policy, and a scheduled restore drill that fails loudly instead of silently.</li>

<li><strong>Plugin policy audits.</strong> Checking your live plugin list against a specific host&#8217;s disallowed list before migration, and finding replacements for anything that fails.</li>

<li><strong>Monitoring that answers the right question.</strong> Grafana and Prometheus dashboards covering PHP-FPM saturation, database load and cache hit ratio, not just whether the site returns 200.</li>
</ul>



<p class="wp-block-paragraph">Send me something concrete and I&#8217;ll tell you what I see: an access log sample, your current plan and traffic numbers, your active plugin list, or the output of the commands above. That&#8217;s usually enough for a straight answer before either of us talks about scope.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/wordpress/managed-wordpress-hosting-vs-vps/">Managed WordPress Hosting vs VPS: Who Owns the Failure?</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The OOM Killer Took MySQL: Redis Object Caching for WordPress</title>
		<link>https://john-nessime.com/blog/technical-guides/redis-object-cache-wordpress/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 04 Aug 2026 03:39:00 +0000</pubDate>
				<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[Redis]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[Website Speed]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=114</guid>

					<description><![CDATA[<p>MySQL was gone in the morning because the kernel picked it to reclaim memory from Redis. Redis ships configured as a durable datastore, and you are using it as a cache. Setup takes ten minutes; the gotchas are memory limits, shared namespaces and the one enormous key nobody mentions.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/redis-object-cache-wordpress/">The OOM Killer Took MySQL: Redis Object Caching for WordPress</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The site went down overnight. MySQL was not running. The logs showed the kernel&#8217;s OOM killer had chosen it, which is what the kernel does when it needs memory and something has to go.</p>



<p class="wp-block-paragraph">The thing that needed the memory was Redis, installed three weeks earlier to speed up the admin, running with the configuration it shipped with. Redis defaults to no memory limit and a policy of never evicting anything. Left alone it will happily grow until the machine runs out, and then the kernel picks a victim, and it will not necessarily pick Redis.</p>



<p class="wp-block-paragraph">That is the framing for everything below. <strong>Redis object caching for WordPress</strong> is genuinely one of the highest-value changes you can make to a busy site, and Redis out of the box is configured as a durable datastore, not a cache. You are storing data you can regenerate at any time. The defaults assume the opposite.</p>



<p class="wp-block-paragraph">Setup takes ten minutes. The gotchas are the post.</p>



<h2 class="wp-block-heading">What it does, and what it doesn&#8217;t</h2>



<p class="wp-block-paragraph">WordPress has an object cache built in, and by default it lasts exactly one request. Every page load fetches the same options, terms, post meta and user meta from MySQL, uses them, and throws them away. A persistent object cache keeps that data between requests, so the second visitor does not repeat the first visitor&#8217;s queries.</p>



<p class="wp-block-paragraph">Two expectations worth setting before you install anything.</p>



<p class="wp-block-paragraph"><strong>It is not a page cache.</strong> Page caching serves a finished HTML file and never runs PHP. Object caching runs the whole application and makes the database part faster. They live in different drop-ins, do different jobs, and coexist happily.</p>



<p class="wp-block-paragraph"><strong>Its biggest effect is where page caching does not reach.</strong> Logged-in requests, wp-admin, WooCommerce carts and checkouts, REST API calls. If your front end is already cached and fast while the dashboard crawls, an object cache is aimed exactly at your problem.</p>



<h2 class="wp-block-heading">Setup</h2>



<ol class="wp-block-list">
<li><strong>Install Redis</strong> from your distribution&#8217;s packages, and bind it to localhost unless something remote genuinely needs it.</li>
<li><strong>Install a PHP Redis client.</strong> PhpRedis (the PECL extension) is faster than the pure-PHP Predis and is what you want on a real server. Relay is a newer option worth knowing about if you are pushing volume.</li>
<li><strong>Install the Redis Object Cache plugin</strong>, then add the constants below to <code>wp-config.php</code> <em>before</em> enabling the drop-in.</li>
<li><strong>Enable it</strong>, which copies <code>object-cache.php</code> into <code>wp-content/</code>. That file, not the plugin, is what WordPress actually loads.</li>
<li><strong>Verify it end to end</strong>, which is step five for a reason.</li>
</ol>



<pre class="wp-block-code"><code>// wp-config.php, above the "That's all, stop editing" line.

define( 'WP_REDIS_HOST',     '127.0.0.1' );
define( 'WP_REDIS_PORT',     6379 );

// Two different protections, and you want both. The database
// number scopes flushes; the prefix prevents key collisions.
define( 'WP_REDIS_DATABASE', 1 );
define( 'WP_REDIS_PREFIX',   'acme-prod:' );

// Ceiling for keys written with no expiry, so nothing lives forever.
define( 'WP_REDIS_MAXTTL',   86400 );</code></pre>



<p class="wp-block-paragraph">One note if you are following an older tutorial: the prefix constant used to be <code>WP_CACHE_KEY_SALT</code> and was renamed to <code>WP_REDIS_PREFIX</code> in version 2.0 of the plugin, to stop it being confused with a core constant of the same name. The old name is still read for compatibility, but if a guide is still using it, that guide predates a major version and its other advice may too.</p>



<p class="wp-block-paragraph">Then prove it works rather than trusting a green indicator on a settings page:</p>



<pre class="wp-block-code"><code># The plugin's own view.
wp redis status

# Write through WordPress, read it back through WordPress.
wp eval 'wp_cache_set("probe","ok","test",60); echo wp_cache_get("probe","test");'

# Then confirm the key landed in the database and prefix you expect.
redis-cli -n 1 --scan --pattern 'acme-prod:*' | head</code></pre>



<p class="wp-block-paragraph">Site Health under Tools will also tell you whether WordPress considers a persistent object cache to be in use. That is the check to trust, because it reflects what core sees rather than what the plugin believes.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Gotcha 1: the defaults are a datastore&#8217;s defaults</h2>



<p class="wp-block-paragraph">The one from the opening paragraph, and the only one on this list that can take a server down.</p>



<pre class="wp-block-code"><code># /etc/redis/redis.conf

# Without a limit, Redis grows until the machine runs out and the
# kernel kills something. It will not necessarily kill Redis.
maxmemory 512mb

# The default policy is to refuse writes rather than evict. Correct
# for a datastore, wrong for a cache: you want the least recently
# used keys dropped, silently, forever.
maxmemory-policy allkeys-lru

# Cache contents are regenerable by definition. Persisting them buys
# nothing and costs you fork latency on save and slower restarts.
save ""
appendonly no</code></pre>



<p class="wp-block-paragraph">Size <code>maxmemory</code> against what the machine can spare after MySQL and PHP-FPM have what they need, not against what is currently free. Free memory on a healthy Linux box is mostly page cache, and it is doing useful work.</p>



<p class="wp-block-paragraph">The honest trade-off with <code>allkeys-lru</code>: under memory pressure Redis will evict things you would rather it kept, and cache misses go up. That is the correct failure mode. The alternative is write errors surfacing inside WordPress, or an outage.</p>



<p class="wp-block-paragraph">If you are on managed hosting, do not change any of this. Ask them what they have set, because they may have tuned it and may not appreciate you undoing it.</p>



<h2 class="wp-block-heading">Gotcha 2: one Redis, several sites</h2>



<p class="wp-block-paragraph">Redis is a single key-value store. Two WordPress installs pointed at it with default settings both write <code>post_123</code> to the same key and read each other&#8217;s data. On a server hosting several sites that is a correctness bug. Where it gets genuinely nasty is staging.</p>



<p class="wp-block-paragraph">Staging is usually a clone of production, which means it inherits production&#8217;s <code>wp-config.php</code>, which means it inherits production&#8217;s Redis settings. Now both environments share a namespace, and a developer clearing the cache on staging clears production&#8217;s, or worse, staging serves production&#8217;s cached objects.</p>



<p class="wp-block-paragraph">The two constants guard against different things and you want both:</p>



<ul class="wp-block-list">
<li><strong><code>WP_REDIS_DATABASE</code></strong> selects a logical database, of which Redis provides sixteen by default. This scopes flushes, so one site clearing its cache does not clear another&#8217;s.</li>
<li><strong><code>WP_REDIS_PREFIX</code></strong> namespaces every key. This prevents collisions if two installs ever land in the same database, which is what happens the day somebody copies a config file.</li>
</ul>



<p class="wp-block-paragraph">Make the prefix readable and environment-specific: <code>acme-prod:</code> and <code>acme-staging:</code>, not a random string. It is a namespace, not a secret, and you will want to read it in <code>redis-cli</code> at some point.</p>



<p class="wp-block-paragraph">If you use Relay as the client, a dedicated database <em>and</em> a unique prefix per install is a requirement rather than a suggestion.</p>



<h2 class="wp-block-heading">Gotcha 3: some things are never cached, and one thing is enormous</h2>



<p class="wp-block-paragraph">Not every cache group is persistent. WordPress marks some as non-persistent, and plugins can mark their own, so those groups keep behaving exactly as they did before you installed anything. If you expected a specific thing to get faster and it did not, this is a likely reason and it is not a misconfiguration.</p>



<p class="wp-block-paragraph">The more interesting one is <code>alloptions</code>. WordPress stores all autoloaded options as a <em>single</em> cache entry. On a site with a bloated options table, that is one very large key, fetched on every request, and invalidated whenever any autoloaded option is written. Under concurrency that produces a lot of simultaneous re-priming of the same large value.</p>



<p class="wp-block-paragraph">Which means an object cache does not excuse a bloated options table; it changes where the cost lands. Check what your keys actually look like:</p>



<pre class="wp-block-code"><code># Reports the largest key per type. If one key dwarfs everything
# else, it is probably alloptions and your options table needs work.
redis-cli -n 1 --bigkeys</code></pre>



<h2 class="wp-block-heading">Gotcha 4: flushing is not free</h2>



<p class="wp-block-paragraph">Clearing the object cache on a busy site means every subsequent request rebuilds what it needs from MySQL simultaneously. On a quiet site nobody notices. On a busy one you get a load spike at exactly the moment you were trying to fix something.</p>



<p class="wp-block-paragraph">Know what your flush actually does. Some implementations issue a Redis-wide flush; others delete only keys matching your prefix. Those are very different operations when the instance is shared, and finding out during an incident is a bad time.</p>



<p class="wp-block-paragraph">Also worth deciding deliberately: whether deploys flush the cache. Stale cached objects after a plugin update cause genuinely confusing bugs, so a flush in the deploy script is often right. Just do it knowing it costs a load spike, and put it at the start of a quiet window rather than at five o&#8217;clock on a Friday.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<p class="wp-block-paragraph"><strong>&#8220;Drop-in: invalid&#8221; or &#8220;not connected&#8221;.</strong> Usually the PHP extension is missing, or PHP cannot reach Redis on the host and port you gave it. Check with <code>php -m | grep redis</code> and <code>redis-cli ping</code> from the same machine PHP runs on, which is not always the machine you are logged into.</p>



<p class="wp-block-paragraph"><strong>Everything looks connected but nothing is cached.</strong> Something replaced <code>object-cache.php</code>. Optimisation plugins install their own drop-in, and only one can win. Check the file&#8217;s contents, not just that it exists.</p>



<p class="wp-block-paragraph"><strong>The site got slower.</strong> Latency to Redis. A remote instance across a network adds a round trip to every cache operation, and there are a lot of them per request. Localhost or a Unix socket beats a fast network every time.</p>



<p class="wp-block-paragraph"><strong>Stale data after an update.</strong> Flush once, then work out which plugin is caching something it should be invalidating. Do not put a flush on every request to make the symptom go away.</p>



<p class="wp-block-paragraph"><strong>Memory keeps climbing.</strong> Either no <code>maxmemory</code>, or keys written with no expiry and no <code>WP_REDIS_MAXTTL</code> ceiling. Check <code>INFO memory</code> and the eviction counters.</p>



<p class="wp-block-paragraph"><strong>Staging and production interfering.</strong> They are sharing a database number, a prefix, or both. Fix the config on staging as part of your restore script so it cannot recur.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Running Redis with default memory settings on a shared box.</li>
<li>Leaving persistence enabled for a pure cache workload.</li>
<li>No prefix and no database number, so sites read each other&#8217;s objects.</li>
<li>Cloning production to staging without changing the Redis settings.</li>
<li>Trusting the plugin&#8217;s status page instead of Site Health and an actual read-back.</li>
<li>Expecting it to speed up an already page-cached front end.</li>
<li>Installing it and ignoring an options table full of autoloaded junk.</li>
<li>Predis on a production site when PhpRedis is available.</li>
<li>Putting Redis on a different host and adding a network hop to every cache call.</li>
<li>Flushing the cache during peak traffic to fix a display bug.</li>
<li>Two plugins competing for the <code>object-cache.php</code> drop-in.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Set <code>maxmemory</code> and <code>allkeys-lru</code> before you point any site at it.</li>
<li>Disable persistence unless Redis is also doing a job that needs it.</li>
<li>Unique database number and readable prefix per install and per environment.</li>
<li>Bind to localhost, or require a password and TLS if it must be remote.</li>
<li>PhpRedis over Predis, and Redis on the same host as PHP where you can.</li>
<li>A <code>WP_REDIS_MAXTTL</code> ceiling so nothing lives forever by accident.</li>
<li>Verify with Site Health plus a write-and-read test, not a status indicator.</li>
<li>Clean up autoloaded options rather than caching the mess.</li>
<li>Monitor memory used, evicted keys and hit rate; a collapsing hit rate is an early warning.</li>
<li>Make staging&#8217;s Redis config part of the restore script, not something to remember.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Do I need Redis if I already have a caching plugin?</h3>



<p class="wp-block-paragraph">Probably, because they solve different problems. Page caching handles anonymous visitors; object caching handles everything that cannot be page cached, which is the admin, logged-in users and anything transactional. Run both.</p>



<h3 class="wp-block-heading">Redis or Memcached?</h3>



<p class="wp-block-paragraph">For WordPress, Redis, mostly because the tooling and plugin ecosystem around it is better and you get richer diagnostics. Memcached is perfectly capable and slightly simpler. This is not a decision worth agonising over.</p>



<h3 class="wp-block-heading">How much memory should I give it?</h3>



<p class="wp-block-paragraph">Start modestly, watch memory used and evicted keys for a week, and raise it if evictions are constant while free memory remains. Sizing from an article is guessing; sizing from your own eviction counter is not.</p>



<h3 class="wp-block-heading">Is it safe to lose everything in Redis?</h3>



<p class="wp-block-paragraph">Yes, for an object cache. Everything in it is regenerable from MySQL, which is exactly why persistence is unnecessary. Losing it costs a burst of database load, not data.</p>



<h3 class="wp-block-heading">Why did my dashboard not get faster?</h3>



<p class="wp-block-paragraph">Check the cache is genuinely active first, then look at what else is slow: outbound HTTP calls during admin requests, a large autoloaded options blob, or the heartbeat. An object cache removes repeated database work and nothing else.</p>



<h3 class="wp-block-heading">Should staging share the production Redis?</h3>



<p class="wp-block-paragraph">It can, with a different database number and a different prefix. It is cleaner not to. Either way, set it as part of the restore process so a clone never inherits production&#8217;s cache namespace.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Redis ships configured to protect data it assumes you cannot afford to lose. You are storing data you could regenerate in a second. Every default that follows from that assumption, unlimited memory, no eviction, persistence to disk, is wrong for your use case and one of them can take the server down.</p>



<p class="wp-block-paragraph">So set <code>maxmemory</code> and an eviction policy before you enable the drop-in, give every install its own database and prefix, and verify with Site Health rather than a green dot. Then it is genuinely one of the best changes you can make to a WordPress site.</p>



<h2 class="wp-block-heading">Want it set up properly?</h2>



<p class="wp-block-paragraph">Object caching is quick to install and easy to get subtly wrong in ways that show up weeks later. Work I take on:</p>



<ul class="wp-block-list">
<li>Installing and tuning Redis object caching on a VPS or dedicated server, including memory limits, eviction and persistence settings.</li>
<li>Auditing an existing setup for shared namespaces, competing drop-ins and caches that are not actually active.</li>
<li>Multi-site and multi-environment configuration so staging can never touch production&#8217;s cache.</li>
<li>Cleaning up autoloaded options so the object cache is caching something sensible.</li>
<li>Monitoring for Redis memory, evictions and hit rate, wired into Prometheus and Grafana.</li>
<li>Diagnosing sites where caching was installed and nothing got faster.</li>
</ul>



<p class="wp-block-paragraph">Send me your Site Health info and <code>redis-cli INFO memory</code>, and I will tell you what is worth changing.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/redis-object-cache-wordpress/">The OOM Killer Took MySQL: Redis Object Caching for WordPress</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</title>
		<link>https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 03 Aug 2026 12:33:00 +0000</pubDate>
				<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[CLS]]></category>
		<category><![CDATA[Core Web Vitals]]></category>
		<category><![CDATA[INP]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[LCP]]></category>
		<category><![CDATA[PageSpeed Insights]]></category>
		<category><![CDATA[SEO]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[Website Speed]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=95</guid>

					<description><![CDATA[<p>You got PageSpeed Insights to 98 and six weeks later Search Console still says the same URLs are failing. The score and the assessment measure different things, and Lighthouse cannot measure INP at all. Here is how to fix Core Web Vitals on WordPress in the order that actually moves the report.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/">A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You spent a fortnight on it. Deferred the scripts, converted everything to WebP, turned on a caching plugin, tuned the CDN. PageSpeed Insights now returns 98 on desktop and 92 on mobile, and you screenshot it for the client.</p>



<p class="wp-block-paragraph">Six weeks later the Core Web Vitals report in Search Console still says the same URLs are failing. Nothing moved.</p>



<p class="wp-block-paragraph">Here is why, and it is the single most important thing to understand before touching anything: <strong>the score and the assessment are two different measurements of two different things.</strong> The score is Lighthouse running a simulated page load on throttled hardware, once, from wherever you are. The assessment is Chrome telemetry from real visitors on real devices, aggregated over 28 days, graded at the 75th percentile.</p>



<p class="wp-block-paragraph">Worse: Lighthouse cannot measure one of the three metrics at all. Interaction to Next Paint requires an actual human interacting with the page, so no lab tool produces it. You can score 100 and fail INP for every mobile visitor you have.</p>



<p class="wp-block-paragraph">This walks through fixing <strong>Core Web Vitals on WordPress</strong> the way that actually shifts the report: get field data first, then work each metric in the order that pays.</p>



<h2 class="wp-block-heading">Field data first, or you are guessing</h2>



<p class="wp-block-paragraph">Three facts that determine everything else.</p>



<p class="wp-block-paragraph"><strong>You are graded at the 75th percentile.</strong> Three quarters of page views need a good result. Not the median, not your laptop. The fourth-slowest visitor out of four, typically a mid-range Android phone on patchy mobile data, decides whether you pass.</p>



<p class="wp-block-paragraph"><strong>The window is 28 days and it rolls.</strong> Deploy a fix today and the report will not reflect it for weeks, and even then it moves gradually as old data ages out. Anyone promising you a same-week improvement in Search Console does not understand the mechanism.</p>



<p class="wp-block-paragraph"><strong>Thin URLs fall back to origin-level data.</strong> If a page has too few visits for its own dataset, Google uses a group or your whole origin instead. So one badly performing template can drag URLs that are individually fine.</p>



<p class="wp-block-paragraph">Practically: start in the Core Web Vitals report in Search Console to see which templates fail and how many URLs are affected. Then run PageSpeed Insights on a representative URL and read <em>only the field data section at the top</em> to decide what is broken. Use the Lighthouse section below it to work out why, which is what lab data is genuinely good for.</p>



<p class="wp-block-paragraph">If you want faster feedback than 28 days, add real user monitoring. Google&#8217;s <code>web-vitals</code> JavaScript library reports all three metrics from your own visitors and you can send them wherever you like. On a site where performance matters commercially, this is worth the afternoon it takes.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">LCP: the biggest thing on screen</h2>



<p class="wp-block-paragraph">Largest Contentful Paint is when the largest visible element finishes rendering. Good is under 2.5 seconds. On WordPress it is almost always the hero image or the first image in post content, and the causes are boringly consistent.</p>



<h3 class="wp-block-heading">The lazy-loaded hero</h3>



<p class="wp-block-paragraph">Still the most common LCP problem on WordPress sites, years after core started handling it. Core skips lazy loading on the first image in content and adds <code>fetchpriority="high"</code> to the likely LCP candidate, but the heuristic only works on images core knows about. A hero rendered by a page builder, a theme header, or a slider often sits outside that, and some optimisation plugins re-add <code>loading="lazy"</code> to everything regardless.</p>



<pre class="wp-block-code"><code># Look at what the server actually sends. If your hero image has
# loading="lazy" on it, you have found your LCP problem.
curl -s https://example.com/ | grep -o '&lt;img[^&gt;]*&gt;' | head -5</code></pre>



<p class="wp-block-paragraph">For a theme-rendered hero, be explicit rather than hoping a heuristic catches it:</p>



<pre class="wp-block-code"><code>&lt;!-- No loading="lazy". width and height reserve space, which
     also protects CLS. fetchpriority tells the browser this
     is the one image worth fetching before everything else. --&gt;
&lt;img src="/hero.webp"
     width="1600" height="900"
     fetchpriority="high"
     decoding="async"
     alt=""&gt;</code></pre>



<h3 class="wp-block-heading">Slow server response</h3>



<p class="wp-block-paragraph">LCP cannot be faster than time to first byte. On shared hosting with no page cache, an uncached WordPress response can eat most of your budget before a single byte of image is requested. Full-page caching is the single highest-yield change on most WordPress sites, and a CDN in front of it removes the geographic distance as well.</p>



<p class="wp-block-paragraph">If TTFB is still poor with caching on, the cache is being bypassed. Cookies from analytics, a cart, or a logged-in session will do it. Check the cache-status header on a real request rather than trusting the plugin&#8217;s dashboard.</p>



<h3 class="wp-block-heading">Render-blocking CSS</h3>



<p class="wp-block-paragraph">The browser will not paint until it has the stylesheets. A theme loading four separate CSS files plus a font stylesheet plus an icon library delays every LCP candidate on the page. Self-host fonts with <code>font-display: swap</code>, drop icon libraries you use six glyphs from, and be sceptical of &#8220;combine CSS&#8221; plugin features, which on HTTP/2 often make things worse rather than better.</p>



<h3 class="wp-block-heading">Speculative loading, which is free</h3>



<p class="wp-block-paragraph">Worth knowing because most WordPress performance guides predate it. Since WordPress 6.8, core ships speculative loading using the Speculation Rules API: the browser starts fetching a link&#8217;s destination when the visitor interacts with it, so the next page is partly or entirely loaded before they arrive. It is on by default for logged-out visitors, using prefetch with &#8220;conservative&#8221; eagerness, and it is a progressive enhancement with no downside for browsers that do not support it.</p>



<p class="wp-block-paragraph">You can push it further, at a cost:</p>



<pre class="wp-block-code"><code>add_filter( 'wp_speculation_rules_configuration', function ( $config ) {
    // Prerender actually renders the next page, including running
    // its JavaScript. Much faster, and analytics or anything with
    // side effects may fire for a visit that never happens.
    return array(
        'mode'      =&gt; 'prerender',
        'eagerness' =&gt; 'moderate',
    );
} );</code></pre>



<p class="wp-block-paragraph">Test that carefully on anything transactional. Core also honours <code>no-prefetch</code> and <code>no-prerender</code> CSS classes on individual links, which is the simplest way to keep carts, checkouts and logout links out of it.</p>



<h2 class="wp-block-heading">INP: the hard one, and it is your JavaScript</h2>



<p class="wp-block-paragraph">Interaction to Next Paint measures the time from a user interacting to the browser painting the response, across every interaction on the page, and reports roughly the worst one. Good is under 200 milliseconds; above 500 is poor.</p>



<p class="wp-block-paragraph">It replaced First Input Delay in March 2024, and the change matters more than a metric swap sounds. FID measured only the delay before processing the first interaction. INP measures the whole round trip, every time. Plenty of sites that comfortably passed FID fail INP, because the thing that was always sluggish is now the thing being graded.</p>



<p class="wp-block-paragraph">Since no lab tool reports it, use Total Blocking Time in Lighthouse as a proxy, and Chrome DevTools&#8217; Performance panel to find which handler is blocking. On WordPress the usual suspects:</p>



<ul class="wp-block-list">
<li><strong>Plugin JavaScript loading site-wide.</strong> A form builder, a slider and a popup tool each shipping their bundle on every page, including pages that use none of them.</li>
<li><strong>Third-party tags.</strong> Tag managers, chat widgets, heatmaps, ad scripts. These run on the main thread and are usually the largest single contributor.</li>
<li><strong>Long tasks in event handlers.</strong> Anything doing real work synchronously in response to a click.</li>
<li><strong>jQuery-era code</strong> attaching handlers to hundreds of elements on a listing page.</li>
</ul>



<p class="wp-block-paragraph">The fixes, in order of yield. First, stop shipping JavaScript to pages that do not use it. Conditionally dequeue plugin assets rather than accepting that a contact form script belongs on every article. This alone often solves INP.</p>



<p class="wp-block-paragraph">Second, audit the third-party tags. Every one is somebody&#8217;s request from two years ago, and at least one of them is for a tool nobody uses any more. Removing a tag is the cheapest performance work available.</p>



<p class="wp-block-paragraph">Third, break up long tasks so the browser can paint between chunks:</p>



<pre class="wp-block-code"><code>// Yielding lets the browser respond to the click that started this,
// instead of finishing all the work first. INP measures exactly
// that gap, so yielding moves the metric even though total work
// stays the same.
async function processAll(items) {
  for (const item of items) {
    doWork(item);
    await new Promise(resolve =&gt; setTimeout(resolve, 0));
  }
}</code></pre>



<p class="wp-block-paragraph">Newer Chrome versions offer <code>scheduler.yield()</code>, which does the same thing without losing your place in the task queue. Feature-detect it and fall back to the above.</p>



<p class="wp-block-paragraph">One honest warning: &#8220;delay JavaScript until user interaction&#8221; features in optimisation plugins look like an INP fix and frequently are not. Delaying scripts until the first interaction means the first interaction now waits for all of them to load and execute, which is the worst possible moment. It improves lab scores and can make INP worse.</p>



<h2 class="wp-block-heading">CLS: usually an afternoon</h2>



<p class="wp-block-paragraph">Cumulative Layout Shift measures unexpected movement of visible content. Good is under 0.1, and it is the metric most likely to be fixable in one sitting.</p>



<ul class="wp-block-list">
<li><strong>Images without dimensions.</strong> Set <code>width</code> and <code>height</code> so the browser reserves space before the file arrives. WordPress does this for media library images; hand-written theme markup often does not.</li>
<li><strong>Ads and embeds.</strong> Reserve a fixed-height container. An ad slot that collapses to nothing and then expands is a large shift on every page view.</li>
<li><strong>Fonts.</strong> A web font swapping in at a different metric reflows everything. <code>font-display: swap</code> plus <code>size-adjust</code> on the fallback, or a system font stack, both work.</li>
<li><strong>Cookie banners and notices injected by JavaScript</strong> that push the page down rather than overlaying it.</li>
<li><strong>Lazy-loaded content above the fold</strong> that appears after paint.</li>
</ul>



<p class="wp-block-paragraph">Chrome DevTools&#8217; Performance panel highlights layout shifts with the element that moved, which turns this from detective work into a checklist.</p>



<h2 class="wp-block-heading">The order I&#8217;d work in</h2>



<ol class="wp-block-list">
<li><strong>Anything in the &#8220;poor&#8221; band</strong>, whichever metric it is. Poor hurts more than two mediocre metrics.</li>
<li><strong>Caching and TTFB</strong>, because it is one change and it moves LCP for every page at once.</li>
<li><strong>The LCP image</strong> on your highest-traffic template.</li>
<li><strong>JavaScript you can delete.</strong> Unused plugin assets and dead third-party tags, for INP.</li>
<li><strong>CLS</strong>, which is quick and visible to users even where the number already passes.</li>
<li><strong>Everything else</strong>, measured one change at a time.</li>
</ol>



<p class="wp-block-paragraph">What I would skip: chasing a green Lighthouse score once field data passes, stacking a second optimisation plugin on top of the first, and micro-optimising a metric that is already comfortably good. There is no bonus for 1.1 seconds when 2.5 is the bar.</p>



<h2 class="wp-block-heading">When the numbers don&#8217;t move</h2>



<p class="wp-block-paragraph"><strong>It has been two weeks.</strong> That is normal. The window is 28 days and rolling, so give it a full month before concluding anything.</p>



<p class="wp-block-paragraph"><strong>Lab improved, field did not.</strong> You optimised something the lab measures and users do not experience, or the problem is INP, which the lab never measured. Go back to the field data section.</p>



<p class="wp-block-paragraph"><strong>Desktop passes, mobile fails.</strong> Expected, and mobile is what is graded for most sites. Test with CPU throttling on, because the gap is usually processing rather than network.</p>



<p class="wp-block-paragraph"><strong>One URL is fine but the group fails.</strong> Origin-level fallback, or a different template in the same group is dragging it. Check what else Search Console has bundled together.</p>



<p class="wp-block-paragraph"><strong>It regressed after a plugin update.</strong> Very common, and the reason to record your field numbers before and after any significant change rather than relying on memory.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Treating the PageSpeed Insights score as the thing being measured.</li>
<li>Optimising INP with a lab tool that cannot measure INP.</li>
<li>Testing from a fast laptop on fibre and concluding the site is fine.</li>
<li>Lazy-loading the hero image, or letting a plugin re-add it.</li>
<li>Running two optimisation plugins that both minify and both defer.</li>
<li>Enabling &#8220;delay JavaScript until interaction&#8221; and expecting INP to improve.</li>
<li>Judging a fix after a week.</li>
<li>Loading every plugin&#8217;s JavaScript on every page.</li>
<li>Adding a CDN while the origin has no page cache.</li>
<li>Fixing the homepage when the failing template is the blog archive.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Decide what is broken from field data; work out why from lab data.</li>
<li>Fix by template, not by URL, since one template covers thousands of pages.</li>
<li>Full-page caching first, then a CDN, then image work.</li>
<li>Explicit <code>width</code> and <code>height</code> on every image in theme markup.</li>
<li><code>fetchpriority="high"</code> on the LCP image and lazy loading on nothing above the fold.</li>
<li>Dequeue plugin assets on pages that do not use them.</li>
<li>Review third-party tags on a schedule and delete the ones nobody owns.</li>
<li>Self-host fonts with <code>font-display: swap</code>.</li>
<li>Real user monitoring so you are not waiting a month for feedback.</li>
<li>Record field numbers before and after every significant change.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">Why is my PageSpeed score 95 but Search Console says I&#8217;m failing?</h3>



<p class="wp-block-paragraph">They measure different things. The score is a simulated load on your connection; the assessment is real Chrome users at the 75th percentile over 28 days. Read the field data section at the top of PageSpeed Insights, not the score.</p>



<h3 class="wp-block-heading">How long before a fix shows up?</h3>



<p class="wp-block-paragraph">The data window is 28 days and rolls daily, so improvement appears gradually and takes about a month to fully reflect. Real user monitoring gives you same-day feedback if you cannot wait.</p>



<h3 class="wp-block-heading">Will a caching plugin fix Core Web Vitals?</h3>



<p class="wp-block-paragraph">It will help LCP substantially, because TTFB is a floor under it. It does very little for INP, which is about JavaScript execution, and nothing for CLS. Caching is a strong first move and not a complete answer.</p>



<h3 class="wp-block-heading">What happened to FID?</h3>



<p class="wp-block-paragraph">INP replaced it in March 2024 and FID is retired. Any guide still listing it predates the change, which is a useful signal about the rest of its advice.</p>



<h3 class="wp-block-heading">Do I need to switch themes?</h3>



<p class="wp-block-paragraph">Sometimes, and it is a bigger project than it looks. A heavy multipurpose theme loading a page builder, a slider library and an icon set on every request is fighting you. Measure first, because a theme swap on a site with hundreds of pages is rarely the cheapest route to passing.</p>



<h3 class="wp-block-heading">Does passing actually help rankings?</h3>



<p class="wp-block-paragraph">Core Web Vitals are a ranking signal among many, and content relevance dominates. The reliable win is on the user side: faster pages convert better and get abandoned less. Treat ranking as a bonus rather than the business case.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">You cannot fix what you are not measuring, and the tool most people measure with cannot see the metric most people are failing. Lighthouse is a debugger. The Chrome User Experience Report is the exam.</p>



<p class="wp-block-paragraph">So open Search Console before you open PageSpeed Insights, find out which of the three is actually red, and fix that one. Then wait a month before deciding whether it worked, because the measurement takes longer than the work.</p>



<h2 class="wp-block-heading">Site failing and not sure why?</h2>



<p class="wp-block-paragraph">Most of the WordPress sites I get sent have already had a fortnight thrown at the wrong metric. Work I take on:</p>



<ul class="wp-block-list">
<li>Diagnosing which Core Web Vital is actually failing, for which templates, from field data rather than a score.</li>
<li>LCP work: caching and TTFB, image priority and formats, render-blocking CSS, CDN configuration.</li>
<li>INP work, which is mostly JavaScript archaeology: dequeuing plugin assets by page, auditing third-party tags, breaking up long tasks.</li>
<li>CLS fixes across templates, including ads, embeds, fonts and consent banners.</li>
<li>Setting up real user monitoring so you get feedback in a day instead of a month.</li>
<li>Untangling sites running two or three overlapping optimisation plugins.</li>
</ul>



<p class="wp-block-paragraph">Send me your Search Console Core Web Vitals report and a URL from a failing template, and I will tell you what is actually wrong.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/">A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
