<?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 Hosting | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/wordpress-hosting/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/wordpress-hosting/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 16:32:06 +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 Hosting | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/wordpress-hosting/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<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>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>
		<item>
		<title>Your Site Is Fast and Your Dashboard Is Not: Fixing a Slow WordPress Admin</title>
		<link>https://john-nessime.com/blog/wordpress/slow-wordpress-admin/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:34 +0000</pubDate>
				<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Autoloaded Options]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[Database Optimization]]></category>
		<category><![CDATA[Heartbeat API]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[Query Monitor]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WordPress Debugging]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[wp-admin]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<category><![CDATA[WP-Cron]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=58</guid>

					<description><![CDATA[<p>Your homepage loads instantly and your dashboard takes eight seconds. That is not a contradiction: wp-admin is the only part of your site that never gets cached. Here is where the time actually goes, in the order worth checking.</p>
<p>The post <a href="https://john-nessime.com/blog/wordpress/slow-wordpress-admin/">Your Site Is Fast and Your Dashboard Is Not: Fixing a Slow WordPress Admin</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 the site is fine but the dashboard takes eight seconds. You check the homepage: loads instantly, green across the board in every speed test you throw at it. You log in, click Posts, and watch the spinner. Same server, same database, same plugins. One is fast and one is not.</p>



<p class="wp-block-paragraph">Here is the thing that explains almost every case of this, and it is worth getting straight before you touch a single setting: <strong>wp-admin is the only part of your site that never gets cached.</strong> Every page cache plugin, every CDN, every reverse proxy in front of WordPress explicitly excludes logged-in requests. It has to, otherwise you would serve one editor&#8217;s draft screen to another. So the front end you keep measuring is a static HTML file being handed over by Cloudflare or Nginx, and the dashboard is your actual application running end to end, every single time.</p>



<p class="wp-block-paragraph">A <strong>slow WordPress admin</strong> is not a separate problem from a slow site. It is the same problem, visible. The cache was hiding it, not fixing it.</p>



<p class="wp-block-paragraph">This post walks through where that time actually goes, in the order I check things: how to tell server time from browser time, the <code>wp_options</code> table, outbound HTTP requests that block your page load, admin-ajax and the heartbeat, wp-cron, database bloat, and the server underneath it. Plus the troubleshooting steps for the cases that do not fit the pattern.</p>



<h2 class="wp-block-heading">First, split the problem in two</h2>



<p class="wp-block-paragraph">Do not start changing things. Find out whether you are waiting on the server or on the browser, because the fixes have nothing in common.</p>



<p class="wp-block-paragraph">Open the browser dev tools, go to the Network tab, hard reload an admin page, and look at the document request. If TTFB on that first request is three seconds, the server is thinking and nothing you do to JavaScript will help. If TTFB is 200ms and the page still feels slow, you have a rendering problem: too many scripts, a bloated admin notice area, a media grid loading hundreds of full-size images.</p>



<p class="wp-block-paragraph">For server-side time, install <a href="https://wordpress.org/plugins/query-monitor/" target="_blank" rel="noreferrer noopener">Query Monitor</a>. It is the single most useful diagnostic tool in the WordPress ecosystem and it costs nothing. It gives you, per admin page load: total queries and their time, which queries came from which plugin, every HTTP request WordPress made server-side and how long each took, hook timings, and PHP errors. Nine times out of ten the answer is sitting in the HTTP API panel or the Queries panel, and you will have it in under a minute.</p>



<p class="wp-block-paragraph">One caution: Query Monitor itself adds overhead, so use it to find the culprit, then deactivate it before you measure the improvement.</p>



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



<h2 class="wp-block-heading">The invisible one: your autoloaded options</h2>



<p class="wp-block-paragraph">This is the failure that hides for years and then bites everybody at once, and it is where I look first on any slow WordPress admin.</p>



<p class="wp-block-paragraph">WordPress loads every option marked as autoload in one query at the start of <em>every single request</em>, then unserializes the lot into memory. That design is fine when the total is a few dozen kilobytes. It stops being fine when a page builder decides to autoload its full asset manifest, a slider plugin stores every slide&#8217;s settings in one row, an analytics plugin caches an API response there, and three plugins you uninstalled two years ago left their rows behind.</p>



<p class="wp-block-paragraph">Now every admin click pulls several megabytes out of MySQL and runs it through PHP&#8217;s unserializer before WordPress has drawn anything. No error, no warning, no slow query in the log worth noticing. Just a tax on every request.</p>



<h3 class="wp-block-heading">Measuring it</h3>



<p class="wp-block-paragraph">There is a trap in the usual SQL query you will find online. WordPress 6.6 changed the <code>autoload</code> column from a simple yes/no to a set of values: <code>on</code>, <code>off</code>, <code>auto</code>, <code>auto-on</code>, <code>auto-off</code>. Older rows keep <code>yes</code> and <code>no</code>, and there is no upgrade routine, so a real site has a mix. Querying only for <code>autoload = 'yes'</code> undercounts, sometimes badly.</p>



<pre class="wp-block-code"><code>-- Total bytes loaded on every request.
-- Adjust wp_ if your table prefix is different.
SELECT SUM(LENGTH(option_value)) AS autoload_bytes
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on');

-- The worst offenders, largest first.
SELECT option_name, LENGTH(option_value) AS size_bytes
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on')
ORDER BY size_bytes DESC
LIMIT 25;</code></pre>



<p class="wp-block-paragraph">If you have WP-CLI, this is quicker and you do not need database credentials:</p>



<pre class="wp-block-code"><code># Total size of everything autoloaded, in bytes.
wp option list --autoload=on --format=total_bytes

# Biggest autoloaded options. Note the sort happens in the shell:
# wp option list only sorts by option_id, option_name or option_value.
wp option list --autoload=on --fields=option_name,size_bytes | sort -n -k 2 | tail -25</code></pre>



<p class="wp-block-paragraph">Site Health flags this too, under Tools then Site Health, once the total crosses core&#8217;s threshold. Treat that warning as an audit trigger rather than a number to chase.</p>



<h3 class="wp-block-heading">Fixing it without breaking anything</h3>



<p class="wp-block-paragraph">Read the list before you delete anything. You are looking for three categories:</p>



<ul class="wp-block-list">
<li><strong>Orphans.</strong> Options with the prefix of a plugin that is no longer installed. Safe to delete after a backup.</li>
<li><strong>Wrongly autoloaded.</strong> Settings for a plugin that only ever runs on one admin screen. These should not be autoloaded, but they should not be deleted either.</li>
<li><strong>Cache data in the wrong place.</strong> A plugin storing an API response or an asset map as a permanent option. Usually safe to delete, because the plugin will rebuild it, but check that assumption on staging first.</li>
</ul>



<p class="wp-block-paragraph">For the middle category, flip autoload off instead of deleting. Use the WordPress API rather than raw SQL, because a direct <code>UPDATE</code> leaves the object cache holding the old value and you will spend twenty minutes convinced nothing changed:</p>



<pre class="wp-block-code"><code># Stop autoloading a specific option. Run once, then remove.
wp eval "wp_set_option_autoload( 'some_plugin_bulky_setting', false );"</code></pre>



<p class="wp-block-paragraph">While you are in there, clear out expired transients. They live in the same table and orphaned ones accumulate quietly, especially on sites that have been through a few plugin migrations:</p>



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



<h2 class="wp-block-heading">Outbound HTTP requests that block your page load</h2>



<p class="wp-block-paragraph">This one is regional and it catches people out badly. WordPress makes server-side HTTP calls during admin requests: core update checks, plugin and theme update checks, the Events and News dashboard widget, and whatever your commercial plugins do to phone home for licence validation.</p>



<p class="wp-block-paragraph">Those calls are synchronous. If the endpoint is slow to reach from your server, or a firewall is silently dropping the packets rather than refusing them, PHP sits there until the timeout expires. Two plugins doing that with a five-second timeout each is ten seconds of dashboard, and absolutely nothing in the interface tells you why.</p>



<p class="wp-block-paragraph">Query Monitor&#8217;s HTTP API panel shows every one of these with its duration, which is usually enough. To confirm from the server directly:</p>



<pre class="wp-block-code"><code># How long does the server take to reach the WordPress.org API?
# -w prints the timing, -o discards the body, -s hides the progress meter.
curl -w "%{time_total}n" -o /dev/null -s https://api.wordpress.org/core/version-check/1.7/</code></pre>



<p class="wp-block-paragraph">If that takes seconds, or hangs, you have found it. For a pure diagnostic, you can temporarily block all outbound HTTP from WordPress and see whether the dashboard snaps back:</p>



<pre class="wp-block-code"><code>// wp-config.php, ABOVE the "stop editing" line.
// DIAGNOSTIC ONLY. This breaks updates, licence checks and
// anything else that calls out. Remove it once you have your answer.
define( 'WP_HTTP_BLOCK_EXTERNAL', true );

// Optionally allow specific hosts back through while testing.
define( 'WP_ACCESSIBLE_HOSTS', 'api.wordpress.org,*.wordpress.org' );</code></pre>



<p class="wp-block-paragraph">Do not leave that in place as a fix. If outbound connectivity is genuinely the problem, the real solution is at the network layer: fix the firewall rule, fix DNS resolution on the box, or move to a host that does not throttle outbound connections. If you are on shared hosting and cannot influence any of that, a small VPS from somewhere like InterServer will usually behave better than a crowded shared box, purely because you control the network stack.</p>



<p class="wp-block-paragraph">The dashboard news widget is the one piece here you can just remove, and most teams never look at it:</p>



<pre class="wp-block-code"><code>// In a site-specific plugin, not in a parent theme.
add_action( 'wp_dashboard_setup', function () {
    remove_meta_box( 'dashboard_primary', 'dashboard', 'side' );
} );</code></pre>



<h2 class="wp-block-heading">admin-ajax.php and the heartbeat</h2>



<p class="wp-block-paragraph">Open the Network tab in dev tools, filter on <code>admin-ajax.php</code>, and leave a dashboard tab sitting idle for two minutes. You will see requests firing on a timer. That is the Heartbeat API, and it powers autosave, post locking, and session expiry warnings. Those are real features and you should not rip them out.</p>



<p class="wp-block-paragraph">The cost is that each tick boots WordPress, loads every active plugin, runs any handlers hooked into it, and occupies a PHP worker for the duration. The post editor ticks fastest because autosave needs to feel responsive; other admin screens tick more slowly. Multiply by every open tab and every logged-in editor, and on a small server you can spend a meaningful share of your PHP workers on polling.</p>



<p class="wp-block-paragraph">The proportionate fix is to slow it down rather than kill it:</p>



<pre class="wp-block-code"><code>// Slow the heartbeat down across admin screens.
// Core clamps this to its allowed range, so an out-of-range
// value is quietly ignored rather than applied.
add_filter( 'heartbeat_settings', function ( $settings ) {
    $settings['interval'] = 60;
    return $settings;
} );</code></pre>



<p class="wp-block-paragraph">Worth being honest about the trade-off: at a longer interval, autosave and post-lock detection get less immediate. On a single-author blog that is invisible. On a newsroom where three people edit the same article, post locking becoming less prompt is a genuine cost and you should leave the editor alone and throttle elsewhere.</p>



<p class="wp-block-paragraph">Heartbeat is not the only thing hitting that endpoint. Cart fragments, live search, popup builders, notification pollers and bots all target <code>admin-ajax.php</code>. Look at the <code>action</code> parameter in the request payload to see which plugin is responsible, then deal with that plugin rather than blaming the endpoint.</p>



<h2 class="wp-block-heading">wp-cron riding along on your admin requests</h2>



<p class="wp-block-paragraph">WordPress has no scheduler of its own. It checks for due tasks on page loads and fires them off, which means somebody&#8217;s page view pays for your backup job, your feed import and your email queue. On a low-traffic site the person unlucky enough to be that page view is usually you, logged in, clicking around the admin.</p>



<pre class="wp-block-code"><code># What is scheduled, and is anything badly overdue?
wp cron event list

# Run everything due right now, so you can time it in isolation.
wp cron event run --due-now</code></pre>



<p class="wp-block-paragraph">A long list of overdue events means cron is not firing reliably, which is its own problem. The fix is to take the scheduler off page loads and give it to the operating system:</p>



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



<pre class="wp-block-code"><code># crontab -e, as the user that owns the WordPress files.
# Every five minutes is enough for most sites.
*/5 * * * * cd /var/www/example.com &amp;&amp; wp cron event run --due-now &gt;/dev/null 2&gt;&amp;1</code></pre>



<p class="wp-block-paragraph">The order matters. Set the constant and the system cron in the same change, because if you disable wp-cron and forget the cron job, scheduled posts stop publishing and nobody notices for a week.</p>



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



<h2 class="wp-block-heading">Database bloat, and the list tables that trip over it</h2>



<p class="wp-block-paragraph">Admin list screens do work the front end never does. Counting posts by status for those &#8220;All | Published | Draft&#8221; links, joining postmeta for sortable columns, building filter dropdowns from distinct values. All of it scales with table size, and none of it is cached.</p>



<pre class="wp-block-code"><code># Which tables are actually large?
wp db size --tables --human-readable</code></pre>



<p class="wp-block-paragraph">What usually turns up:</p>



<ul class="wp-block-list">
<li><strong><code>wp_postmeta</code> far larger than <code>wp_posts</code>.</strong> Normal to a point. Suspicious when the ratio is extreme, which usually means a plugin writing meta per page view.</li>
<li><strong>Revisions.</strong> Every save keeps a full copy of the post. Cap them with <code>define( 'WP_POST_REVISIONS', 10 );</code> in wp-config, which limits new ones without touching existing content.</li>
<li><strong>Action Scheduler tables</strong> on WooCommerce sites. Completed actions accumulate. There is a cleanup screen under WooCommerce tools, and it is worth checking before you assume the store itself is slow.</li>
<li><strong>Spam and trashed comments</strong> still counted by every dashboard widget that shows a comment count.</li>
</ul>



<p class="wp-block-paragraph">Two quick wins that need no database work at all. Use Screen Options at the top right of any list table to drop the per-page count from twenty to ten on a slow screen. And switch the Media Library from grid view to list view, because the grid loads far more per scroll.</p>



<h2 class="wp-block-heading">The server underneath</h2>



<p class="wp-block-paragraph">If the application is clean and the admin is still slow, look down a layer. Site Health covers most of it, under Tools then Site Health then Info.</p>



<ul class="wp-block-list">
<li><strong>No persistent object cache.</strong> Without one, every options lookup and every transient read goes to MySQL on every request. Adding Redis or Memcached with the matching drop-in is often the single biggest improvement available to an admin-heavy site, because the admin is exactly the workload that benefits.</li>
<li><strong>OPcache disabled.</strong> PHP recompiling every WordPress file on every request. Free to enable, immediately noticeable.</li>
<li><strong>An old PHP version.</strong> Upgrading a WordPress site&#8217;s PHP is usually the cheapest performance work available, and it is a security matter regardless.</li>
<li><strong>PHP-FPM worker exhaustion.</strong> If requests are queuing for a worker, every page waits, and the admin waits longest because it holds a worker for the whole request. Check the pool status and the error log before adding more workers, because a worker shortage is often a symptom of slow requests rather than the cause.</li>
<li><strong>A low memory limit.</strong> The admin needs more than the front end, especially plugin and update screens.</li>
</ul>



<p class="wp-block-paragraph">If you run your own server, track admin response time the same way you track front-end response time. Scraping PHP-FPM and MySQL metrics into Prometheus and graphing them in Grafana costs an afternoon and turns &#8220;the dashboard feels slower lately&#8221; into something you can actually see.</p>



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



<h3 class="wp-block-heading">Only one admin screen is slow</h3>



<p class="wp-block-paragraph">That points at a specific plugin, not the platform. Query Monitor on that exact screen will name the caller. Common culprits are the plugins list (update checks for every installed plugin) and any settings page that fetches remote data to populate a dropdown.</p>



<h3 class="wp-block-heading">Slow only for some users</h3>



<p class="wp-block-paragraph">Check user meta. Some plugins store per-user data such as dismissed notices, column preferences or activity logs, and one user&#8217;s row can grow enormous. Also check whether the slow users are administrators, since admins load update checks and notices that editors do not.</p>



<h3 class="wp-block-heading">Fast for a while after clearing cache, then slow again</h3>



<p class="wp-block-paragraph">Something is rebuilding a large transient or option on a schedule and it is expensive. Watch <code>wp_options</code> row sizes over a day, or check the cron list for a job that lines up with the slowdown.</p>



<h3 class="wp-block-heading">Slow in one browser or on one machine only</h3>



<p class="wp-block-paragraph">Not a server problem. Test in a private window with extensions disabled. Ad blockers and password managers interact badly with some admin screens, and a stale service worker from a caching plugin can serve half-broken admin assets.</p>



<h3 class="wp-block-heading">Everything looks fine but it is still slow</h3>



<p class="wp-block-paragraph">Bisect on staging, never production. Deactivate all plugins, confirm the admin is fast, then reactivate in halves rather than one at a time. Switch to a default theme too, because <code>functions.php</code> in a commercial theme can do as much work as a plugin.</p>



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



<p class="wp-block-paragraph">Things I would push back on if you suggested them:</p>



<ul class="wp-block-list">
<li>Judging admin performance by front-end speed test scores. Those measure a cached HTML file.</li>
<li>Installing a second caching plugin because the first one &#8220;did not fix the dashboard&#8221;. No page cache will ever cache wp-admin.</li>
<li>Running an <code>UPDATE</code> against the <code>autoload</code> column directly, then wondering why nothing changed. The object cache still holds the old values.</li>
<li>Only counting <code>autoload = 'yes'</code> and concluding the table is fine.</li>
<li>Disabling the heartbeat completely, then losing work when two people edit the same post.</li>
<li>Setting <code>DISABLE_WP_CRON</code> without adding a real cron job.</li>
<li>Deleting rows from <code>wp_options</code> without a backup, or without checking on staging first.</li>
<li>Leaving <code>WP_HTTP_BLOCK_EXTERNAL</code> in place because it made the dashboard fast. It also stopped your security updates.</li>
<li>Adding PHP-FPM workers to hide slow requests instead of finding out why they are slow.</li>
<li>Deactivating plugins one at a time on a live site during business hours.</li>
</ul>



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



<ul class="wp-block-list">
<li>Measure with Query Monitor before changing anything, and measure again with it deactivated.</li>
<li>Check autoloaded option size whenever you install, remove or update a major plugin.</li>
<li>Remove uninstalled plugins&#8217; leftover rows as part of routine maintenance, not as an emergency.</li>
<li>Run a persistent object cache on any site where people spend real time in the admin.</li>
<li>Move wp-cron to a system cron job on every site you manage.</li>
<li>Cap post revisions rather than deleting them in bulk later.</li>
<li>Keep a staging copy so bisecting plugins never happens on production.</li>
<li>Treat Site Health warnings as a starting point for investigation, not a checklist to silence.</li>
<li>Uninstall plugins properly instead of deactivating them and leaving them installed, since update checks still run for installed plugins.</li>
<li>Watch admin response time over time, so you catch a slow regression before somebody files a ticket.</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 WordPress dashboard slow when the site itself is fast?</h3>



<p class="wp-block-paragraph">Because the front end is being served from a cache and the dashboard cannot be. Page caches and CDNs bypass logged-in requests by design, so wp-admin runs the full PHP and MySQL stack on every click. The dashboard is showing you your site&#8217;s real uncached performance.</p>



<h3 class="wp-block-heading">Will a caching plugin speed up wp-admin?</h3>



<p class="wp-block-paragraph">Not the page caching part, no. What can help is the object caching side, if your caching plugin ships one, because that caches option and transient lookups rather than whole pages. Redis or Memcached with the matching drop-in does the same job more reliably.</p>



<h3 class="wp-block-heading">How much autoloaded data is too much?</h3>



<p class="wp-block-paragraph">Core&#8217;s Site Health check will tell you when you have crossed its threshold, and that is the number to work against rather than one from an article. More usefully: if a single option is measured in hundreds of kilobytes, look at it regardless of the total, because one bad row is easier to fix than a general diet.</p>



<h3 class="wp-block-heading">Is it safe to delete rows from wp_options?</h3>



<p class="wp-block-paragraph">With a backup and a staging test, yes for orphaned rows from plugins you have removed. For anything belonging to an active plugin, turn autoload off instead of deleting, and use <code>wp_set_option_autoload()</code> rather than raw SQL so the object cache stays consistent.</p>



<h3 class="wp-block-heading">Should I disable the Heartbeat API?</h3>



<p class="wp-block-paragraph">Throttle it, do not disable it. Turning it off entirely breaks autosave and post locking, which is a bad trade for a site with more than one editor. Raising the interval keeps both working and removes most of the load.</p>



<h3 class="wp-block-heading">Does the number of plugins matter?</h3>



<p class="wp-block-paragraph">Less than people assume. Twenty well-written plugins can cost less than one that runs a remote API call on <code>admin_init</code>. Count what each plugin does per request, not how many are in the list. Query Monitor attributes queries and HTTP calls to specific plugins, which turns this from a guess into a measurement.</p>



<h3 class="wp-block-heading">Will better hosting fix a slow WordPress admin?</h3>



<p class="wp-block-paragraph">It helps when the constraint is genuinely the server: no OPcache, no object cache, throttled CPU, a shared MySQL instance under load. It does nothing for four megabytes of autoloaded options or a plugin blocking on a remote API. Diagnose first, because moving a slow site to a faster server usually gives you a slightly less slow site and a new bill.</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">Your dashboard is not a separate, badly built part of WordPress. It is the only place you ever see your site running without a cache in front of it, which makes it the most honest performance signal you have. A slow WordPress admin is telling you something true about the application, and the front end has been politely covering for it.</p>



<p class="wp-block-paragraph">So measure before you change anything, and go in this order: autoloaded options, outbound HTTP requests, admin-ajax traffic, cron, database size, then the server. That sequence puts the highest-yield, lowest-risk checks first, and most of the time you will not get past the second one.</p>



<h2 class="wp-block-heading">Need someone to find where the time is going?</h2>



<p class="wp-block-paragraph">Diagnosing a slow admin is mostly measurement discipline, and it goes faster with someone who has looked at a lot of these. Work I take on regularly:</p>



<ul class="wp-block-list">
<li>Profiling a slow WordPress admin and reporting back with the actual cause, named, rather than a list of generic tips.</li>
<li>Cleaning up <code>wp_options</code> and autoloaded data safely, tested on staging before it touches production.</li>
<li>Setting up Redis or Memcached object caching, OPcache and PHP-FPM tuning on a VPS or dedicated server.</li>
<li>Moving wp-cron to system cron and fixing scheduled jobs that have quietly stopped running.</li>
<li>Database maintenance: revisions, orphaned meta, Action Scheduler cleanup on WooCommerce sites, and indexing where it genuinely helps.</li>
<li>Adding proper monitoring so admin performance regressions show up on a graph instead of in a complaint.</li>
</ul>



<p class="wp-block-paragraph">Send me a Site Health info report and a Query Monitor screenshot from your slowest admin screen, and I will tell you what I would look at first.</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/slow-wordpress-admin/">Your Site Is Fast and Your Dashboard Is Not: Fixing a Slow WordPress Admin</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Two Sources of Truth: Git-Based WordPress Deployment From Local to Production</title>
		<link>https://john-nessime.com/blog/devops/git-based-wordpress-deployment/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:28 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[Bedrock]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Composer]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Git]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[rsync]]></category>
		<category><![CDATA[Staging]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[WordPress Database]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WordPress Migration]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=60</guid>

					<description><![CDATA[<p>A plugin quietly reverts after every deploy and nobody knows why. That is what happens when Git and wp-admin both think they own the file tree. Here is how to build a deployment workflow where the repo is the source of truth and the database only ever travels one way.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/git-based-wordpress-deployment/">Two Sources of Truth: Git-Based WordPress Deployment From Local to Production</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone reports that the contact form stopped working. You check the log, and nothing has touched that plugin in three weeks. You SSH into production, look at the plugin directory, and the version on disk is older than the version in your repo. Older than production had yesterday, in fact.</p>



<p class="wp-block-paragraph">Then it clicks. Somebody logged into wp-admin last month, saw an update notice, clicked it. The plugin got updated on the server and never in git. Last night&#8217;s deploy synced the repo over the top and put the old version back, along with the vulnerability the update was patching. No error, no warning. The deploy did exactly what it was told.</p>



<p class="wp-block-paragraph">That is the failure mode that kills most attempts at <strong>Git-based WordPress deployment</strong>, and it has nothing to do with your pipeline. WordPress is designed to modify itself. It installs plugins, updates core, writes files, all from the browser. The moment you put it under version control you have two systems that both believe they own the file tree, and they will quietly overwrite each other until somebody notices.</p>



<p class="wp-block-paragraph">This post covers the whole chain: what belongs in the repo, how to shut the second write path, how configuration differs per environment, which way the database is allowed to travel, and how to make staging safe enough that you can restore production data into it without emailing your customers by accident.</p>



<h2 class="wp-block-heading">The two things that make WordPress awkward under Git</h2>



<p class="wp-block-paragraph">Everything in this workflow follows from two facts.</p>



<p class="wp-block-paragraph"><strong>State is split across files and a database.</strong> Your theme is code. Your posts are data. But so are your plugin settings, your menus, your widget layouts, and your permalink structure, and those live in the database next to the content. Git only manages half your site, and it is not obvious which half a given change landed in.</p>



<p class="wp-block-paragraph"><strong>The two halves travel in opposite directions.</strong> Code moves forward, local to staging to production. Content moves backward, production to staging to local. Once a site is live, production is the only authoritative source of orders, comments and posts, and pushing a database upward destroys everything written since your last pull.</p>



<p class="wp-block-paragraph">Write that rule somewhere the whole team can see it. Almost every catastrophic WordPress deployment story is somebody pushing a database in the wrong direction.</p>



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



<h2 class="wp-block-heading">Decide what actually goes in the repo</h2>



<p class="wp-block-paragraph">Three approaches, in increasing order of discipline and payoff. Pick one deliberately rather than drifting into the first.</p>



<ol class="wp-block-list">
<li><strong>Theme only.</strong> The repo contains one custom theme, sometimes a custom plugin. Everything else is installed through wp-admin. Easy to start, and it means you have no reproducible build: rebuilding the site from the repo is impossible.</li>
<li><strong>All of wp-content, minus uploads.</strong> Themes, plugins and mu-plugins in git; core and media excluded. This is the pragmatic middle and where most teams land. You get reproducible plugin versions and a real diff when a plugin changes.</li>
<li><strong>Composer-managed, Bedrock-style.</strong> Core, plugins and themes are all declared as dependencies. The repo holds a <code>composer.json</code> and your own code, nothing else. The lockfile is the source of truth and <code>composer install</code> rebuilds the site anywhere.</li>
</ol>



<p class="wp-block-paragraph">Option three is the one I reach for on anything with more than one developer, because &#8220;which plugin version is production running&#8221; becomes a question with an answer. The honest cost: premium plugins that are not on a Composer repository need either a private repository or a manual step, and that friction is real. If most of your plugin stack is commercial, option two is a reasonable place to stop.</p>



<p class="wp-block-paragraph">One thing to check if you are following an older tutorial. The Composer repository landscape for WordPress plugins shifted recently: WPackagist was acquired by WP Engine, and the Roots team launched WP Packages as an independent alternative with different package naming. Both work. Any guide written before that change will use the older <code>wpackagist-plugin/</code> prefix, so make sure the repository URL and the prefixes in your <code>composer.json</code> match each other.</p>



<h3 class="wp-block-heading">The gitignore that matters</h3>



<pre class="wp-block-code"><code># Secrets and machine-specific config. Never committed.
wp-config.php
.env

# Content, not code. This gets pulled down from production,
# it never gets deployed up.
wp-content/uploads/

# Generated at runtime or by the build.
wp-content/cache/
wp-content/upgrade/
wp-content/debug.log
node_modules/
vendor/</code></pre>



<p class="wp-block-paragraph">Ignoring <code>vendor/</code> assumes your deploy runs <code>composer install</code>. If your pipeline just rsyncs the repo with no build step, commit it instead, or you will ship a site with no dependencies. Decide which, then be consistent, because half-and-half is how you end up with a missing autoloader at two in the morning.</p>



<h2 class="wp-block-heading">Close the second write path</h2>



<p class="wp-block-paragraph">This is the step people skip, and it is the one that makes everything else work. If production can still install and update plugins from the browser, your repo is not the source of truth. It is a suggestion.</p>



<pre class="wp-block-code"><code>// wp-config.php on production and staging.

// Removes the ability to install, update or delete plugins and
// themes from the admin. Also disables the built-in file editor,
// so DISALLOW_FILE_EDIT is implied by this one.
define( 'DISALLOW_FILE_MODS', true );

// Stops core from updating itself behind your back, including
// the automatic minor updates that would otherwise change files
// your deploy is about to overwrite.
define( 'AUTOMATIC_UPDATER_DISABLED', true );</code></pre>



<p class="wp-block-paragraph">Be clear-eyed about the trade-off, because it is a real one. You have just taken away automatic security updates. That is only an improvement if you replace them with something: a scheduled dependency update job, a weekly review of the update list, a bot that opens a pull request when a plugin version changes. If you disable updates and then do nothing, you have made the site less safe, not more controlled.</p>



<p class="wp-block-paragraph">The update flow now runs through the repo. On your local machine:</p>



<pre class="wp-block-code"><code># Composer-managed: bump the lockfile, commit it, deploy.
composer update wp-plugin/some-plugin

# wp-content-in-git: let WP-CLI do the download locally,
# then commit the resulting file changes as a normal diff.
wp plugin update some-plugin</code></pre>



<p class="wp-block-paragraph">The second form is worth appreciating. A plugin update becomes a reviewable diff. When something breaks two days later, <code>git log</code> tells you exactly which files changed and <code>git revert</code> puts them back.</p>



<h2 class="wp-block-heading">Configuration per environment</h2>



<p class="wp-block-paragraph">Database credentials, salts and API keys differ per environment and none of them belong in git. Keep <code>wp-config.php</code> out of the repo and place it on each server, or use an environment file with a committed <code>.env.example</code> showing the required keys with no values.</p>



<p class="wp-block-paragraph">Core has a first-class way to tell environments apart, and it is underused:</p>



<pre class="wp-block-code"><code>// Per environment, in wp-config.php.
// Recognised values: 'local', 'development', 'staging', 'production'.
define( 'WP_ENVIRONMENT_TYPE', 'staging' );</code></pre>



<pre class="wp-block-code"><code>&lt;?php
// mu-plugin, committed to the repo, safe on every environment
// because it checks where it is running.

if ( wp_get_environment_type() !== 'production' ) {

    // Keep non-production sites out of search results.
    add_filter( 'pre_option_blog_public', '__return_zero' );

    // Short-circuit wp_mail() entirely. Returning a non-null
    // value stops WordPress before it hands anything to PHPMailer.
    add_filter( 'pre_wp_mail', '__return_false' );
}</code></pre>



<p class="wp-block-paragraph">That mail filter is the single most valuable line in this post. Restore a production database onto staging, forget this, and the first cron run mails real order confirmations, password resets and abandoned-cart nudges to real customers from a URL that does not work. It is silent until it is very loud.</p>



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



<h2 class="wp-block-heading">Moving the database in the allowed direction</h2>



<p class="wp-block-paragraph">Set up WP-CLI aliases once and the rest becomes short commands instead of a runbook nobody follows.</p>



<pre class="wp-block-code"><code># wp-cli.yml in the project root, committed to the repo.
@staging:
  ssh: deploy@staging.example.com/var/www/staging.example.com
@production:
  ssh: deploy@example.com/var/www/example.com</code></pre>



<p class="wp-block-paragraph">Now pulling production down to your machine is one pipe. The trailing hyphen means &#8220;write the dump to standard output&#8221;, and the leading hyphen on the import means &#8220;read it from standard input&#8221;, so nothing touches disk:</p>



<pre class="wp-block-code"><code># Back up your local database first, then overwrite it.
wp db export local-backup.sql
wp @production db export - | wp db import -</code></pre>



<p class="wp-block-paragraph">The URLs inside that dump still point at production, and they are buried in serialized PHP arrays where a plain SQL find-and-replace corrupts the string length prefixes. This is exactly what <code>wp search-replace</code> exists for: it unserializes, replaces, and reserializes properly.</p>



<pre class="wp-block-code"><code># Always dry-run first and actually read the table list it prints.
wp search-replace 'https://example.com' 'https://example.test' 
  --all-tables-with-prefix --skip-columns=guid --dry-run

# Then for real.
wp search-replace 'https://example.com' 'https://example.test' 
  --all-tables-with-prefix --skip-columns=guid

wp cache flush</code></pre>



<p class="wp-block-paragraph">Two flags worth understanding rather than copying:</p>



<ul class="wp-block-list">
<li><strong><code>--skip-columns=guid</code></strong> because the <code>guid</code> column is a permanent identifier that feed readers and external systems use to decide whether they have already seen a post. Rewriting it makes subscribers re-receive your entire archive, and you find out from an angry email weeks later.</li>
<li><strong><code>--all-tables-with-prefix</code></strong> because plugins create their own tables using your prefix, and those tables are not registered with WordPress. Without this, the replacement misses them and you get half-migrated URLs in places nobody checks.</li>
</ul>



<p class="wp-block-paragraph">Uploads are the other half of the content problem. Do not put them in git; a media library will outgrow a repository quickly and every clone pays for it. Sync them separately with <code>rsync</code> when you need them, or skip them entirely locally and let a plugin proxy missing images from production. Offloading media to object storage sidesteps the question completely and is worth considering on any site with a large library.</p>



<h2 class="wp-block-heading">The deploy itself</h2>



<p class="wp-block-paragraph">Three broad options. <code>git pull</code> on the server is the simplest and the one I would move away from first: it puts a <code>.git</code> directory next to your document root, it has no build step, and a failed pull leaves the site half-updated. Rsync from CI is the pragmatic default. Atomic releases with a symlink swap, the way Deployer or Capistrano work, are the most correct because the site switches versions instantly and rollback is a symlink change.</p>



<p class="wp-block-paragraph">A workable rsync deploy from GitHub Actions, with staging on merge and production on tag:</p>



<pre class="wp-block-code"><code>name: Deploy
on:
  push:
    branches: [main]     # goes to staging
    tags: ['v*']         # goes to production

jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4    # pin to a full SHA in production

      - name: Build
        run: |
          composer install --no-dev --optimize-autoloader
          npm ci
          npm run build

      - name: Sync files
        run: |
          echo "$DEPLOY_KEY" &gt; deploy_key
          chmod 600 deploy_key
          rsync -az --delete 
            -e "ssh -i deploy_key -o StrictHostKeyChecking=accept-new" 
            --exclude-from=.deployignore 
            ./ "$TARGET"
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
          TARGET: ${{ secrets.DEPLOY_TARGET }}</code></pre>



<p class="wp-block-paragraph"><code>--delete</code> is what makes the target match the source exactly, which is the entire point of a deploy. It is also the flag that will erase your uploads directory if <code>.deployignore</code> is wrong. Test the exclude list against staging before you ever point this at production, and use <code>--dry-run</code> the first time.</p>



<p class="wp-block-paragraph">Files landing on the server is not the same as a working deploy. Run a post-deploy step over SSH:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

cd /var/www/example.com

# Applies schema changes that a core or plugin version bump needs.
# Safe to run when there is nothing to do.
wp core update-db

# Rewrite rules live in the database, so a plugin that registers
# new routes needs this or you get 404s on the new URLs.
wp rewrite flush

# Options and transients are cached; stale ones survive a file deploy.
wp cache flush

# Prove the site answers before you call the deploy finished.
# --fail makes curl exit non-zero on a 5xx instead of shrugging.
curl --fail --silent --show-error https://example.com/ &gt; /dev/null</code></pre>



<p class="wp-block-paragraph">If you run PHP-FPM with OPcache, add a reset to that script. Otherwise PHP happily serves the previous version of your code from its compiled cache and you spend twenty minutes convinced the deploy did not run.</p>



<h2 class="wp-block-heading">Making staging safe to hold real data</h2>



<p class="wp-block-paragraph">Staging is only useful if it mirrors production, and the moment it does it becomes dangerous. Every one of these has bitten somebody:</p>



<ul class="wp-block-list">
<li><strong>Outbound email.</strong> Covered above. Kill it at the <code>wp_mail()</code> level rather than trusting a plugin setting somebody can toggle.</li>
<li><strong>Cron.</strong> A restored production database brings production&#8217;s scheduled jobs with it. Set <code>DISABLE_WP_CRON</code> on staging and do not add a system cron job unless you specifically need to test scheduling.</li>
<li><strong>Payment gateways.</strong> The database carries live API keys. Overwrite them with test-mode credentials as part of your restore script, not by hand afterwards.</li>
<li><strong>Search indexing.</strong> Filter <code>blog_public</code> as shown, and put HTTP basic auth in front of the whole environment. Duplicate content ranking above your real site is an avoidable embarrassment.</li>
<li><strong>Personal data.</strong> A production restore is real customer data on a server that probably has weaker access controls. Either anonymise user records during the restore or treat staging with the same seriousness as production.</li>
</ul>



<p class="wp-block-paragraph">Write the restore as a single script that does all of this in order, so nobody has to remember step four. If it is a checklist in a wiki, it will be skipped.</p>



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



<h3 class="wp-block-heading">The deploy ran but the site is unchanged</h3>



<p class="wp-block-paragraph">In order: OPcache serving compiled bytecode, a page cache or CDN holding the old response, or rsync excluding the directory you edited. Check them in that order. Confirm the file on disk actually changed before blaming anything upstream of it.</p>



<h3 class="wp-block-heading">White screen immediately after deploying</h3>



<p class="wp-block-paragraph">Usually a missing autoloader, meaning <code>vendor/</code> was neither committed nor built. Check the PHP error log rather than guessing. A memory limit lower on the server than locally is the other common cause, and it produces the same blank page.</p>



<h3 class="wp-block-heading">Site works, admin redirects in a loop</h3>



<p class="wp-block-paragraph"><code>siteurl</code> and <code>home</code> in the options table disagree with the URL you are actually using. Almost always a search-replace that missed. Check with <code>wp option get siteurl</code> and <code>wp option get home</code>, and set them explicitly with <code>wp option update</code> rather than running the replacement again.</p>



<h3 class="wp-block-heading">Serialized data broken after a migration</h3>



<p class="wp-block-paragraph">Somebody ran <code>sed</code> or a SQL <code>REPLACE</code> on the dump. The length prefix inside each serialized string no longer matches the string, so PHP silently fails to unserialize and widgets, theme options and ACF fields come back empty. Restore from the dump and redo it with <code>wp search-replace</code>. There is no partial recovery worth attempting.</p>



<h3 class="wp-block-heading">Permalinks return 404 after deploy</h3>



<p class="wp-block-paragraph">Rewrite rules are stored in the database and did not get regenerated. Run <code>wp rewrite flush</code>. If it recurs on every deploy, add it to the post-deploy script permanently.</p>



<h3 class="wp-block-heading">Plugin reverts to an older version after every deploy</h3>



<p class="wp-block-paragraph">Somebody is still updating in wp-admin. That is not a pipeline bug, it is the second write path being open. Set <code>DISALLOW_FILE_MODS</code> and give the team a documented way to request an update.</p>



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



<ul class="wp-block-list">
<li>Committing <code>wp-config.php</code>, and with it your database password and salts.</li>
<li>Putting <code>wp-content/uploads</code> in the repo, then wondering why clones take ten minutes.</li>
<li>Pushing a staging database over production because &#8220;the content is the same&#8221;. It never is.</li>
<li>Using <code>sed</code> or SQL to swap URLs instead of <code>wp search-replace</code>.</li>
<li>Replacing the <code>guid</code> column, and re-sending your archive to every RSS subscriber.</li>
<li>Running <code>rsync --delete</code> against production without testing the exclude list.</li>
<li>Leaving updates enabled in wp-admin, so the repo and the server drift apart silently.</li>
<li>Disabling automatic updates without putting a review process in its place.</li>
<li>Restoring production data onto staging with mail still enabled.</li>
<li>Forgetting <code>DISABLE_WP_CRON</code> on staging, so scheduled jobs fire twice across two environments.</li>
<li>Treating &#8220;the files copied&#8221; as proof the deploy worked, with no smoke test.</li>
</ul>



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



<ul class="wp-block-list">
<li>Make the repo the only way code reaches a server, and enforce it with <code>DISALLOW_FILE_MODS</code>.</li>
<li>Keep code moving forward and content moving backward, and state that rule where the team can see it.</li>
<li>Deploy staging from a branch and production from a tag, so production releases are deliberate.</li>
<li>Dry-run every search-replace and every rsync the first time.</li>
<li>Back up the target database immediately before any deploy that touches it.</li>
<li>Script the staging restore end to end, including mail, cron, keys and anonymisation.</li>
<li>Finish every deploy with a real HTTP request through the public URL, not a file listing.</li>
<li>Use environment-aware code with <code>wp_get_environment_type()</code> instead of commented-out blocks.</li>
<li>Keep uploads out of git and sync or offload them separately.</li>
<li>Replace automatic updates with a scheduled dependency review, and put it in a calendar.</li>
</ul>



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



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



<h3 class="wp-block-heading">Should WordPress core be in the repository?</h3>



<p class="wp-block-paragraph">Only if you are not managing it another way. Committing core works and makes the repo self-contained, at the cost of noisy diffs on every update. Declaring core as a Composer dependency is cleaner and gives you a pinned version without the noise. What you should not do is leave core unmanaged, so nobody can say which version production runs.</p>



<h3 class="wp-block-heading">How do I handle the database in a Git workflow?</h3>



<p class="wp-block-paragraph">You do not put it in Git. Git handles code; the database is handled by scripted pulls in one direction. Production to staging to local, with a search-replace on import. The only time a database legitimately goes upward is the initial launch, before there is real content to lose.</p>



<h3 class="wp-block-heading">What about plugin settings changed in staging?</h3>



<p class="wp-block-paragraph">This is the genuinely hard part and there is no clean answer. Settings live in the database, so they do not deploy. Options are: reapply them by hand in production with a documented checklist, script them as WP-CLI commands in a deploy step, or use a plugin that exports configuration to files. Whichever you pick, write it down, because &#8220;I&#8217;ll remember&#8221; is how a setting gets applied to staging and never production.</p>



<h3 class="wp-block-heading">Is Bedrock necessary?</h3>



<p class="wp-block-paragraph">No. It is a well-made set of defaults for Composer-managed WordPress with environment-based configuration, and it saves assembling those pieces yourself. Plenty of teams run a perfectly disciplined Git deployment with a standard directory layout. Adopt it if you want the conventions; do not adopt it as a prerequisite.</p>



<h3 class="wp-block-heading">Do I need three environments?</h3>



<p class="wp-block-paragraph">You need local and production. Staging earns its cost when you have multiple people, a client who signs off on changes, or a site where breakage costs money. On a personal blog it is overhead. On a store, skipping it means production is your test environment.</p>



<h3 class="wp-block-heading">Can I do this on shared hosting?</h3>



<p class="wp-block-paragraph">Partly. You need SSH and ideally WP-CLI, and plenty of shared plans have neither. Some managed WordPress hosts offer a git push deployment target, which handles the file half for you. If you want the full workflow with your own build steps and post-deploy hooks, a small VPS from a provider like InterServer gives you the SSH access and cron control the workflow assumes.</p>



<h3 class="wp-block-heading">How do I roll back a bad deploy?</h3>



<p class="wp-block-paragraph">Files are easy: redeploy the previous tag, or swap the symlink back if you use atomic releases. The database is the hard part, which is why you take a dump immediately before any deploy that runs a migration. Rolling files back without rolling the database back can leave you worse off than the broken version, so know which of the two actually changed before you act.</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">Git-based WordPress deployment is not really a pipeline problem. The pipeline is the easy part, and you can build a working one in an afternoon. The hard part is making the repo the only way code reaches a server, and then keeping the database flowing in exactly one direction.</p>



<p class="wp-block-paragraph">Get those two right and everything else is detail. Get them wrong and you will keep having the same confusing morning, staring at a plugin version that nobody remembers changing.</p>



<h2 class="wp-block-heading">Want this set up properly on your site?</h2>



<p class="wp-block-paragraph">Retrofitting version control onto a live WordPress site is fiddly work, mostly because you have to do it without downtime and without losing whatever has been changed directly on the server. Things I take on:</p>



<ul class="wp-block-list">
<li>Moving an existing production site into Git without downtime, including reconciling whatever has drifted on the server.</li>
<li>Converting a site to Composer-managed plugins and core, with a working lockfile and a sane update process.</li>
<li>Building local, staging and production environments that actually match, with per-environment configuration.</li>
<li>Deploy pipelines in GitHub Actions or GitLab CI: build, rsync or atomic releases, post-deploy WP-CLI steps, smoke test, rollback path.</li>
<li>Scripted database refreshes from production to staging with mail, cron, API keys and personal data handled automatically.</li>
<li>Server setup for the workflow: SSH deploy users, correct file ownership, OPcache resets, cron.</li>
</ul>



<p class="wp-block-paragraph">Tell me how your site is hosted and how plugins currently get updated, and I will tell you what the first step should be.</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/devops/git-based-wordpress-deployment/">Two Sources of Truth: Git-Based WordPress Deployment From Local to Production</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>WordPress Migration Without Downtime: A Practical Guide to Moving Your Website Safely</title>
		<link>https://john-nessime.com/blog/wordpress/wordpress-migration-without-downtime/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 16 Jul 2026 20:32:36 +0000</pubDate>
				<category><![CDATA[Hosting & Infrastructure]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Website Management]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[DNS Migration]]></category>
		<category><![CDATA[SSL Certificate]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<category><![CDATA[Website Downtime]]></category>
		<category><![CDATA[Website Migration]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WordPress Backup]]></category>
		<category><![CDATA[WordPress Database]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WordPress Migration]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=23</guid>

					<description><![CDATA[<p>Moving a WordPress website involves much more than copying files. This practical migration guide explains how to transfer WordPress safely, minimize downtime, protect SEO, preserve email services, and avoid lost data.</p>
<p>The post <a href="https://john-nessime.com/blog/wordpress/wordpress-migration-without-downtime/">WordPress Migration Without Downtime: A Practical Guide to Moving Your Website Safely</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[


<p class="wp-block-paragraph">Moving a WordPress website sounds simple until the site contains years of content, customer data, business emails, analytics integrations, payment systems, and search-engine rankings that cannot afford to disappear.</p>




<p class="wp-block-paragraph">A successful WordPress migration is not just a matter of copying files from one server to another. It is a controlled infrastructure change involving the website files, database, domain records, SSL certificate, email configuration, caching layers, scheduled tasks, security settings, and third-party integrations.</p>




<p class="wp-block-paragraph">When the process is planned correctly, visitors may never notice that the website has moved. When it is rushed, the result can include broken pages, missing images, database errors, lost form submissions, email disruption, redirect loops, certificate warnings, or several hours of downtime.</p>




<p class="wp-block-paragraph">This guide explains how to migrate a WordPress website safely, how to reduce downtime, what can go wrong, and what should be verified before the old hosting environment is finally switched off.</p>




<h2 class="wp-block-heading">What Is a WordPress Migration?</h2>




<p class="wp-block-paragraph">A WordPress migration is the process of moving a WordPress website from one environment to another. The destination might be a new hosting provider, a cloud server, a virtual private server, a staging environment, a new domain, or a different server configuration.</p>




<p class="wp-block-paragraph">The website may look like a single application from the visitor’s perspective, but several separate components normally need to be transferred:</p>




<ul class="wp-block-list">
<li>WordPress core files</li>
<li>Theme files</li>
<li>Plugin files</li>
<li>Uploaded media</li>
<li>The MySQL or MariaDB database</li>
<li>Configuration files</li>
<li>Custom code and server rules</li>
<li>DNS records</li>
<li>SSL certificates</li>
<li>Email-related DNS records</li>
<li>Cron jobs and scheduled tasks</li>
<li>CDN and caching configuration</li>
<li>Analytics and tracking scripts</li>
<li>External API integrations</li>
</ul>




<p class="wp-block-paragraph">A complete migration must preserve both the visible website and the underlying functionality. Copying the homepage successfully is not enough if contact forms stop sending, checkout sessions fail, redirects break, or administrators can no longer log in.</p>




<h2 class="wp-block-heading">Common Reasons for Migrating a WordPress Website</h2>




<p class="wp-block-paragraph">Website owners migrate WordPress installations for many different reasons. In most cases, the decision is connected to performance, reliability, cost, control, or security.</p>




<h3 class="wp-block-heading">Slow Hosting Performance</h3>




<p class="wp-block-paragraph">A website may outgrow its original shared hosting plan. Pages become slow, the WordPress dashboard takes longer to load, and traffic spikes begin causing resource-limit errors. Moving to a better-managed host, VPS, or cloud server can provide more CPU, memory, storage performance, and configuration flexibility.</p>




<h3 class="wp-block-heading">Frequent Downtime</h3>




<p class="wp-block-paragraph">Unreliable infrastructure can damage customer trust and search visibility. If the current host experiences repeated outages or provides weak technical support, migration may be the most practical long-term solution.</p>




<h3 class="wp-block-heading">Security Concerns</h3>




<p class="wp-block-paragraph">Outdated server software, poor account isolation, weak backup systems, or limited firewall controls can expose a WordPress website to unnecessary risk. A migration can be used as an opportunity to rebuild the site on a cleaner and more secure environment.</p>




<h3 class="wp-block-heading">Hosting Cost Optimization</h3>




<p class="wp-block-paragraph">Some hosting plans become expensive as traffic grows, especially when backups, staging, CDN access, email, malware scanning, or additional storage are charged separately. Migrating to a more suitable platform can reduce costs without sacrificing performance.</p>




<h3 class="wp-block-heading">Business Growth</h3>




<p class="wp-block-paragraph">A small brochure website may eventually become a membership platform, online store, booking portal, or content-heavy publication. The original environment may no longer support the website’s operational requirements.</p>




<h3 class="wp-block-heading">Domain or Branding Changes</h3>




<p class="wp-block-paragraph">Some migrations involve changing the domain name as well as the server. This requires additional planning because internal URLs, redirects, analytics properties, search-engine records, cookies, and third-party callbacks may all depend on the original domain.</p>




<h2 class="wp-block-heading">The Difference Between a Simple Migration and a Production Migration</h2>




<p class="wp-block-paragraph">A small personal blog with few updates can often be moved using a migration plugin. A production website requires more care.</p>




<p class="wp-block-paragraph">Production websites may receive new orders, registrations, comments, bookings, support requests, or form submissions while the migration is taking place. If the database is copied too early and DNS is updated hours later, any data created during that gap may remain only on the old server.</p>




<p class="wp-block-paragraph">That is why a production migration should be treated as a controlled cutover rather than a one-click file transfer. The objective is to minimize the period during which the old and new environments contain different information.</p>




<h2 class="wp-block-heading">What Can Go Wrong During a WordPress Migration?</h2>




<p class="wp-block-paragraph">Most migration problems fall into a predictable set of categories. Knowing them in advance makes the process easier to plan and troubleshoot.</p>




<h3 class="wp-block-heading">Database Connection Errors</h3>




<p class="wp-block-paragraph">The WordPress configuration file may contain the wrong database name, username, password, or database host. The user may also lack the necessary permissions on the destination database.</p>




<h3 class="wp-block-heading">Broken Internal URLs</h3>




<p class="wp-block-paragraph">WordPress stores URLs in the database. If the domain, protocol, folder path, or staging address changes, old references can remain inside posts, menus, widgets, page-builder content, serialized options, and plugin settings.</p>




<h3 class="wp-block-heading">Missing Images or Media Files</h3>




<p class="wp-block-paragraph">The uploads directory may be incomplete, excluded from the transfer, or copied with incorrect ownership and permissions. A site may appear functional while older images silently return 404 errors.</p>




<h3 class="wp-block-heading">PHP Compatibility Problems</h3>




<p class="wp-block-paragraph">The destination server may use a newer PHP version than the original host. An outdated theme or plugin can then generate warnings, fatal errors, blank pages, or broken administrative screens.</p>




<h3 class="wp-block-heading">Redirect Loops</h3>




<p class="wp-block-paragraph">Conflicting HTTPS, proxy, CDN, and WordPress redirect rules can cause the browser to repeatedly redirect between two versions of the same URL.</p>




<h3 class="wp-block-heading">Email Delivery Failure</h3>




<p class="wp-block-paragraph">A migration may affect outgoing form emails, SMTP authentication, domain verification, or mailbox routing. DNS changes can also unintentionally overwrite MX, SPF, DKIM, or DMARC records.</p>




<h3 class="wp-block-heading">SSL Certificate Warnings</h3>




<p class="wp-block-paragraph">The destination server may not yet have a valid certificate, or the certificate may not cover every hostname used by the website. Mixed-content warnings can also appear when pages load images, scripts, or stylesheets through HTTP.</p>




<h3 class="wp-block-heading">Lost Transactions or Form Submissions</h3>




<p class="wp-block-paragraph">This is one of the most serious risks. If visitors continue using the old website after the final database backup, their activity may not exist on the new server.</p>




<h2 class="wp-block-heading">Phase One: Audit the Existing Website</h2>




<p class="wp-block-paragraph">Before copying anything, document the current environment. A migration is much safer when the source system is fully understood.</p>




<p class="wp-block-paragraph">The audit should include:</p>




<ul class="wp-block-list">
<li>Current WordPress version</li>
<li>PHP version and PHP extensions</li>
<li>Database engine and version</li>
<li>Active theme and child theme</li>
<li>Active and inactive plugins</li>
<li>Disk usage</li>
<li>Database size</li>
<li>Upload directory size</li>
<li>Web server type</li>
<li>Caching configuration</li>
<li>Scheduled cron tasks</li>
<li>CDN or reverse proxy usage</li>
<li>External SMTP provider</li>
<li>Payment and booking integrations</li>
<li>DNS provider</li>
<li>SSL configuration</li>
<li>Backup availability</li>
</ul>




<p class="wp-block-paragraph">This audit often reveals problems that already existed before the migration. For example, a plugin may be abandoned, the database may contain excessive transient records, or the site may still rely on an unsupported PHP version.</p>




<p class="wp-block-paragraph">A migration should not automatically copy every old problem into the new environment. It is often the right time to clean up unused plugins, remove abandoned themes, review administrator accounts, and confirm that all essential components remain supported.</p>




<h2 class="wp-block-heading">Phase Two: Prepare the Destination Server</h2>




<p class="wp-block-paragraph">The destination environment should be ready before the website is transferred. This reduces last-minute troubleshooting during the cutover.</p>




<p class="wp-block-paragraph">The server preparation usually includes:</p>




<ul class="wp-block-list">
<li>Creating the hosting account or virtual host</li>
<li>Installing the correct PHP version</li>
<li>Enabling required PHP extensions</li>
<li>Creating the database and database user</li>
<li>Configuring file permissions</li>
<li>Setting PHP memory and upload limits</li>
<li>Configuring the web server</li>
<li>Preparing SSL issuance</li>
<li>Configuring backups</li>
<li>Installing monitoring or logging tools</li>
<li>Preparing caching and performance settings</li>
</ul>




<p class="wp-block-paragraph">The destination should ideally match the source environment closely during the first transfer. Major software upgrades can be performed later, after the website is stable. Combining a hosting migration, PHP upgrade, plugin cleanup, theme redesign, and domain change into one maintenance window increases the number of possible failure points.</p>




<h2 class="wp-block-heading">Phase Three: Create Verified Backups</h2>




<p class="wp-block-paragraph">A migration should never begin without a complete backup. More importantly, the backup should be verified rather than merely assumed to be usable.</p>




<p class="wp-block-paragraph">At minimum, create separate copies of:</p>




<ul class="wp-block-list">
<li>The entire WordPress file system</li>
<li>The website database</li>
<li>The WordPress configuration file</li>
<li>Web server rules</li>
<li>DNS records</li>
<li>SSL-related information</li>
<li>Custom cron jobs</li>
<li>Email routing records</li>
</ul>




<p class="wp-block-paragraph">A backup is only valuable if it can be restored. Confirm that the archive opens correctly, the database export is not empty or truncated, and the files include the full uploads directory.</p>




<p class="wp-block-paragraph">For business-critical websites, keeping more than one backup copy is sensible. One copy may remain on the source server, while another is stored externally.</p>




<h2 class="wp-block-heading">Phase Four: Copy the WordPress Files</h2>




<p class="wp-block-paragraph">The files can be transferred using several methods, including a migration plugin, hosting control panel, SFTP, SCP, rsync, or a compressed archive.</p>




<p class="wp-block-paragraph">For small websites, a plugin may be sufficient. Larger websites often benefit from server-level transfer tools because they handle large file sets more efficiently and provide better visibility into transfer errors.</p>




<p class="wp-block-paragraph">During the transfer, confirm that hidden files are included. Files such as <code>.htaccess</code> may contain essential rewrite, security, redirect, or caching rules.</p>




<p class="wp-block-paragraph">After copying, verify ownership and permissions. Incorrect permissions can prevent uploads, plugin updates, cache creation, or WordPress core operations.</p>




<h2 class="wp-block-heading">Phase Five: Export and Import the Database</h2>




<p class="wp-block-paragraph">The database contains the website’s posts, pages, users, settings, menus, plugin data, WooCommerce orders, form entries, and most application-level configuration.</p>




<p class="wp-block-paragraph">It can be exported using tools such as phpMyAdmin, WP-CLI, a migration plugin, or the database command line. Large databases are generally more reliable to move through command-line tools because browser-based imports may fail due to upload or execution limits.</p>




<p class="wp-block-paragraph">Once imported, update the destination <code>wp-config.php</code> file with the correct database details:</p>




<pre class="wp-block-code"><code>define('DB_NAME', 'destination_database');
define('DB_USER', 'destination_user');
define('DB_PASSWORD', 'strong_password');
define('DB_HOST', 'localhost');</code></pre>





<p class="wp-block-paragraph">The exact database host depends on the hosting environment. Some providers use a remote database hostname instead of <code>localhost</code>.</p>




<h2 class="wp-block-heading">Phase Six: Handle URL Changes Correctly</h2>




<p class="wp-block-paragraph">If the website keeps the same domain, fewer URL changes may be required. However, staging URLs, temporary hostnames, HTTP-to-HTTPS changes, subdirectory changes, or domain changes require a controlled search-and-replace operation.</p>




<p class="wp-block-paragraph">A plain text replacement inside a database export can damage serialized WordPress data. WordPress plugins and page builders often store structured values where string lengths matter.</p>




<p class="wp-block-paragraph">Use a WordPress-aware tool such as WP-CLI:</p>




<pre class="wp-block-code"><code>wp search-replace 'https://old-domain.example' 'https://new-domain.example' --all-tables --precise --dry-run</code></pre>





<p class="wp-block-paragraph">The dry run displays the expected replacements without modifying the database. Once reviewed, the operation can be repeated without the <code>--dry-run</code> flag.</p>




<p class="wp-block-paragraph">After the replacement, check:</p>




<ul class="wp-block-list">
<li>Page and post links</li>
<li>Navigation menus</li>
<li>Image URLs</li>
<li>Page-builder templates</li>
<li>Widgets</li>
<li>Logo and favicon paths</li>
<li>Canonical tags</li>
<li>Open Graph images</li>
<li>Plugin callback URLs</li>
<li>Webhook endpoints</li>
</ul>




<h2 class="wp-block-heading">Phase Seven: Test the Website Before Changing DNS</h2>




<p class="wp-block-paragraph">The destination website should be tested before public traffic is sent to it. This is one of the most important steps in a low-downtime migration.</p>




<p class="wp-block-paragraph">A local hosts-file override can map the production domain to the new server only on your own computer. This allows the website to be tested under its real domain while the rest of the internet continues reaching the old server.</p>




<p class="wp-block-paragraph">Testing should cover more than the homepage.</p>




<h3 class="wp-block-heading">Front-End Testing</h3>




<ul class="wp-block-list">
<li>Homepage</li>
<li>Posts and pages</li>
<li>Category and archive pages</li>
<li>Search functionality</li>
<li>Navigation menus</li>
<li>Images and downloads</li>
<li>Responsive layout</li>
<li>404 page</li>
<li>Redirects</li>
</ul>




<h3 class="wp-block-heading">Administrative Testing</h3>




<ul class="wp-block-list">
<li>WordPress login</li>
<li>Post editing</li>
<li>Media uploads</li>
<li>Plugin settings</li>
<li>Theme customization</li>
<li>User management</li>
<li>Scheduled tasks</li>
</ul>




<h3 class="wp-block-heading">Business Function Testing</h3>




<ul class="wp-block-list">
<li>Contact forms</li>
<li>Newsletter subscriptions</li>
<li>Account registration</li>
<li>Password reset emails</li>
<li>Checkout and payment flow</li>
<li>Booking forms</li>
<li>Customer dashboards</li>
<li>API integrations</li>
<li>Webhook delivery</li>
</ul>




<p class="wp-block-paragraph">Check the browser console, server logs, WordPress debug logs, and network requests. A page can appear normal while scripts, API calls, or background processes are failing silently.</p>




<h2 class="wp-block-heading">How to Reduce WordPress Migration Downtime</h2>




<p class="wp-block-paragraph">Zero downtime is difficult to guarantee for every website, but downtime can often be reduced to a very small window.</p>




<h3 class="wp-block-heading">Lower the DNS TTL in Advance</h3>




<p class="wp-block-paragraph">DNS Time to Live determines how long resolvers may cache a record. Lowering the TTL before migration can help changes propagate more quickly.</p>




<p class="wp-block-paragraph">This should be done before the migration window, not at the exact moment of cutover. Existing cached records will continue using the previous TTL until they expire.</p>




<h3 class="wp-block-heading">Perform an Initial Copy Before Cutover</h3>




<p class="wp-block-paragraph">Copy the website files and database to the destination in advance. This gives enough time to test the environment and resolve compatibility problems before the final switch.</p>




<h3 class="wp-block-heading">Freeze Content Changes Briefly</h3>




<p class="wp-block-paragraph">For dynamic websites, a short maintenance or content-freeze window may be necessary. During this period, editors should stop publishing and customers may need to be temporarily prevented from submitting transactions.</p>




<h3 class="wp-block-heading">Perform a Final Database Synchronization</h3>




<p class="wp-block-paragraph">After the initial copy and testing, export the latest database immediately before the DNS or proxy cutover. This minimizes the chance of losing recent activity.</p>




<h3 class="wp-block-heading">Keep the Old Server Available</h3>




<p class="wp-block-paragraph">Do not cancel the original hosting account as soon as DNS is changed. Some visitors may still reach the old server due to DNS caching. The source environment should remain available until propagation is complete and the new website has been monitored.</p>




<h2 class="wp-block-heading">DNS Migration: More Than Changing One Record</h2>




<p class="wp-block-paragraph">DNS errors are responsible for many migration failures. A domain may have records for the website, email, verification services, subdomains, APIs, and third-party platforms.</p>




<p class="wp-block-paragraph">Before making changes, export or document the complete DNS zone. Important records may include:</p>




<ul class="wp-block-list">
<li>A records</li>
<li>AAAA records</li>
<li>CNAME records</li>
<li>MX records</li>
<li>TXT records</li>
<li>SPF records</li>
<li>DKIM records</li>
<li>DMARC records</li>
<li>Domain verification records</li>
<li>Service-specific subdomains</li>
</ul>




<p class="wp-block-paragraph">If only the web server is changing, there may be no reason to modify email records. Accidentally replacing the entire DNS zone with a simplified version can interrupt business email even when the website itself works correctly.</p>




<h2 class="wp-block-heading">SSL and HTTPS After Migration</h2>




<p class="wp-block-paragraph">The destination server must have a valid SSL certificate for the production domain. The certificate should cover all required hostnames, such as the root domain and the <code>www</code> version.</p>




<p class="wp-block-paragraph">After enabling HTTPS, verify that:</p>




<ul class="wp-block-list">
<li>The certificate is valid and not expired</li>
<li>The certificate matches the domain</li>
<li>HTTP redirects to HTTPS correctly</li>
<li>The root and www versions behave consistently</li>
<li>No redirect loop exists</li>
<li>Images and scripts load through HTTPS</li>
<li>CDN resources use secure URLs</li>
</ul>




<p class="wp-block-paragraph">Mixed-content errors should be corrected rather than hidden. They usually indicate that some database values, theme settings, CSS files, or external resources still reference HTTP URLs.</p>




<h2 class="wp-block-heading">Email and Contact Form Verification</h2>




<p class="wp-block-paragraph">A website migration is not complete until email functionality is tested.</p>




<p class="wp-block-paragraph">WordPress may rely on the local server mail function, SMTP authentication, a transactional email provider, or an external mailbox service. Each method has different requirements.</p>




<p class="wp-block-paragraph">Test at least the following:</p>




<ul class="wp-block-list">
<li>Contact form delivery</li>
<li>Administrator notifications</li>
<li>Password reset emails</li>
<li>WooCommerce order emails</li>
<li>Registration emails</li>
<li>Newsletter confirmation messages</li>
<li>SMTP authentication</li>
</ul>




<p class="wp-block-paragraph">Also confirm that SPF, DKIM, and DMARC records remain valid. Successful message submission does not always mean successful inbox delivery.</p>




<h2 class="wp-block-heading">Migrating a WooCommerce Website</h2>




<p class="wp-block-paragraph">WooCommerce migrations require additional care because the database may change continuously. Orders, inventory, customer records, carts, subscriptions, refunds, and payment events can be created during the migration window.</p>




<p class="wp-block-paragraph">A WooCommerce migration plan should include:</p>




<ul class="wp-block-list">
<li>A clearly defined cutover time</li>
<li>A short checkout freeze if required</li>
<li>A final database synchronization</li>
<li>Payment gateway testing</li>
<li>Webhook testing</li>
<li>Order email verification</li>
<li>Inventory validation</li>
<li>Subscription and scheduled-payment checks</li>
<li>Customer login testing</li>
<li>Transaction log review</li>
</ul>




<p class="wp-block-paragraph">For high-traffic stores, a simple export-and-import workflow may not be sufficient. More advanced database synchronization or application-level migration techniques may be needed.</p>




<h2 class="wp-block-heading">Migrating WordPress to a New Domain</h2>




<p class="wp-block-paragraph">Changing the domain adds an SEO and application layer to the migration.</p>




<p class="wp-block-paragraph">In addition to moving the website, the process should include:</p>




<ul class="wp-block-list">
<li>Updating WordPress URLs</li>
<li>Replacing old domain references</li>
<li>Creating page-to-page 301 redirects</li>
<li>Updating canonical URLs</li>
<li>Updating XML sitemaps</li>
<li>Updating analytics properties</li>
<li>Updating Search Console records</li>
<li>Updating social profiles</li>
<li>Updating payment callbacks</li>
<li>Updating API allowlists</li>
<li>Updating email addresses where required</li>
</ul>




<p class="wp-block-paragraph">A blanket redirect from every old URL to the new homepage is not a good migration strategy. Each important old URL should redirect to its closest equivalent on the new domain.</p>




<h2 class="wp-block-heading">Protecting SEO During WordPress Migration</h2>




<p class="wp-block-paragraph">A hosting migration that keeps the same domain should normally have limited SEO impact when implemented correctly. A domain change or large structural change requires more planning.</p>




<p class="wp-block-paragraph">Important SEO checks include:</p>




<ul class="wp-block-list">
<li>Preserving existing URL structures</li>
<li>Maintaining page titles and metadata</li>
<li>Keeping canonical tags correct</li>
<li>Preserving structured data</li>
<li>Checking robots.txt</li>
<li>Checking XML sitemaps</li>
<li>Verifying redirects</li>
<li>Removing accidental noindex directives</li>
<li>Checking HTTP status codes</li>
<li>Monitoring crawl errors</li>
<li>Monitoring organic traffic after launch</li>
</ul>




<p class="wp-block-paragraph">Staging environments are often configured with <code>noindex</code> rules to prevent search engines from indexing them. That setting must not be carried into production accidentally.</p>




<p class="wp-block-paragraph">It is also important to remove temporary password protection, development redirects, and staging-specific canonical URLs before launch.</p>




<h2 class="wp-block-heading">Performance Optimization After Migration</h2>




<p class="wp-block-paragraph">A migration provides a good opportunity to improve performance, but optimization should be performed methodically.</p>




<p class="wp-block-paragraph">Start by confirming that the website is stable. Then review:</p>




<ul class="wp-block-list">
<li>Page caching</li>
<li>Object caching</li>
<li>PHP OPcache</li>
<li>Database performance</li>
<li>Image compression</li>
<li>WebP or AVIF delivery</li>
<li>CDN integration</li>
<li>Browser caching</li>
<li>Gzip or Brotli compression</li>
<li>Slow plugins</li>
<li>Unused scripts and styles</li>
<li>External API delays</li>
</ul>




<p class="wp-block-paragraph">Do not enable several caching systems at the same time without understanding how they interact. Duplicate caching layers can create stale pages, login problems, checkout issues, and difficult-to-diagnose behavior.</p>




<h2 class="wp-block-heading">Security Checks After Migration</h2>




<p class="wp-block-paragraph">The new environment should be reviewed before the migration is considered complete.</p>




<p class="wp-block-paragraph">Post-migration security checks should include:</p>




<ul class="wp-block-list">
<li>Removing temporary migration files</li>
<li>Removing exported database archives</li>
<li>Deleting unused administrator accounts</li>
<li>Changing temporary credentials</li>
<li>Confirming file ownership and permissions</li>
<li>Disabling directory listing</li>
<li>Reviewing firewall rules</li>
<li>Enabling brute-force protection</li>
<li>Updating WordPress, themes, and plugins</li>
<li>Reviewing backup access</li>
<li>Protecting configuration files</li>
<li>Confirming malware scanning</li>
</ul>




<p class="wp-block-paragraph">Database exports and full-site archives often contain sensitive information. They should not remain publicly accessible inside the website directory after migration.</p>




<h2 class="wp-block-heading">WordPress Migration Plugins vs Manual Migration</h2>




<p class="wp-block-paragraph">Both methods are valid. The right choice depends on the website.</p>




<h3 class="wp-block-heading">Migration Plugins</h3>




<p class="wp-block-paragraph">Migration plugins are convenient for small and medium-sized websites. They can package files and databases, update URLs, and simplify restoration.</p>




<p class="wp-block-paragraph">However, they may encounter:</p>




<ul class="wp-block-list">
<li>File size limits</li>
<li>PHP execution time limits</li>
<li>Memory limits</li>
<li>Hosting restrictions</li>
<li>Archive extraction failures</li>
<li>Large database timeouts</li>
<li>Limited visibility into errors</li>
</ul>




<h3 class="wp-block-heading">Manual or Server-Level Migration</h3>




<p class="wp-block-paragraph">Manual migration provides more control and is often more reliable for large, customized, or business-critical websites.</p>




<p class="wp-block-paragraph">It also allows the migration specialist to inspect logs, control database operations, preserve permissions, perform incremental transfers, and troubleshoot individual layers separately.</p>




<p class="wp-block-paragraph">The tradeoff is that server-level migration requires stronger knowledge of Linux, databases, DNS, web servers, PHP, SSL, and WordPress internals.</p>




<h2 class="wp-block-heading">A Practical WordPress Migration Checklist</h2>




<h3 class="wp-block-heading">Before Migration</h3>




<ul class="wp-block-list">
<li>Audit the current website</li>
<li>Record PHP and database versions</li>
<li>List active integrations</li>
<li>Export DNS records</li>
<li>Lower DNS TTL if needed</li>
<li>Create and verify backups</li>
<li>Prepare the destination server</li>
<li>Install required PHP extensions</li>
<li>Create the destination database</li>
<li>Plan the maintenance window</li>
</ul>




<h3 class="wp-block-heading">During Migration</h3>




<ul class="wp-block-list">
<li>Copy WordPress files</li>
<li>Export and import the database</li>
<li>Update wp-config.php</li>
<li>Perform safe URL replacement</li>
<li>Verify permissions</li>
<li>Test using a hosts-file override</li>
<li>Check logs and browser errors</li>
<li>Test forms, logins, and transactions</li>
<li>Perform the final database sync</li>
<li>Update DNS</li>
</ul>




<h3 class="wp-block-heading">After Migration</h3>




<ul class="wp-block-list">
<li>Verify SSL</li>
<li>Test email delivery</li>
<li>Check redirects</li>
<li>Check sitemap and robots.txt</li>
<li>Clear all cache layers</li>
<li>Review application logs</li>
<li>Monitor uptime</li>
<li>Monitor search and analytics data</li>
<li>Keep the old server available temporarily</li>
<li>Remove migration archives</li>
<li>Confirm backup schedules</li>
</ul>




<h2 class="wp-block-heading">When Should You Hire a WordPress Migration Specialist?</h2>




<p class="wp-block-paragraph">A simple personal website may be moved successfully with a migration plugin. Professional assistance becomes more valuable when the website has operational or commercial importance.</p>




<p class="wp-block-paragraph">Consider hiring a specialist when:</p>




<ul class="wp-block-list">
<li>The website generates sales or leads</li>
<li>Downtime has a direct business cost</li>
<li>The database changes frequently</li>
<li>The site uses WooCommerce or memberships</li>
<li>The migration includes a domain change</li>
<li>Email services share the same DNS zone</li>
<li>The site contains custom plugins or code</li>
<li>The destination server requires manual configuration</li>
<li>The site has a large media library or database</li>
<li>You need SSL, DNS, redirects, and testing handled together</li>
</ul>




<p class="wp-block-paragraph">A professional migration should provide more than a copied website. It should include preparation, backups, testing, DNS coordination, SSL validation, post-migration verification, and a rollback plan.</p>




<h2 class="wp-block-heading">Final Thoughts</h2>




<p class="wp-block-paragraph">WordPress migration is a technical process, but the real objective is business continuity. Visitors should continue reaching the website, customers should continue completing transactions, administrators should retain access, and search engines should continue finding the same content.</p>




<p class="wp-block-paragraph">The safest migrations are rarely the fastest-looking ones. They are the migrations where the destination is prepared in advance, backups are verified, the website is tested before cutover, DNS is handled carefully, and the old environment remains available until the transition is confirmed.</p>




<p class="wp-block-paragraph">Whether the website is moving to a new hosting company, a VPS, a cloud server, or a new domain, a structured migration plan dramatically reduces the risk of downtime, missing data, and unexpected failures.</p>




<div class="wp-block-group" style="border-width:1px;border-radius:12px;margin-top:48px;padding-top:32px;padding-right:32px;padding-bottom:32px;padding-left:32px"><div class="wp-block-group__inner-container is-layout-constrained wp-container-core-group-is-layout-6f60fb98 wp-block-group-is-layout-constrained">



<h2 class="wp-block-heading has-text-align-center">Need Help Migrating Your WordPress Website?</h2>




<p class="has-text-align-center wp-block-paragraph">A WordPress migration should not put your website, customer data, email delivery, or search rankings at risk. I provide complete WordPress migration services covering website files, databases, DNS, SSL, testing, redirects, and post-migration verification.</p>




<p class="has-text-align-center wp-block-paragraph">Your website will be prepared, transferred, tested, and launched through a controlled migration process designed to minimize downtime and prevent avoidable disruption.</p>




<div class="wp-block-buttons is-content-justification-center is-layout-flex wp-container-core-buttons-is-layout-fe48e5de wp-block-buttons-is-layout-flex">



<div class="wp-block-button has-custom-width wp-block-button__width-75">
<a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/services/product/development-it-wordpress-website-migration-with-minimal-downtime-2077830746744627334" target="_blank" rel="noopener sponsored" style="border-radius:8px;padding-top:14px;padding-right:24px;padding-bottom:14px;padding-left:24px">View My WordPress Migration Service on Upwork</a>
</div>


</div>


</div></div>


<p>The post <a href="https://john-nessime.com/blog/wordpress/wordpress-migration-without-downtime/">WordPress Migration Without Downtime: A Practical Guide to Moving Your Website Safely</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
