<?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>SPF | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/spf/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/spf/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 16:25:36 +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>SPF | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/spf/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Zoho Mail Not Receiving Email? A DNS Troubleshooting Guide</title>
		<link>https://john-nessime.com/blog/technical-guides/zoho-mail-not-receiving-email/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 31 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Networking]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[dig]]></category>
		<category><![CDATA[DKIM]]></category>
		<category><![CDATA[DMARC]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[DNS Cache]]></category>
		<category><![CDATA[DNS Records]]></category>
		<category><![CDATA[DNS Troubleshooting]]></category>
		<category><![CDATA[DNSSEC]]></category>
		<category><![CDATA[Domain Configuration]]></category>
		<category><![CDATA[Email Authentication]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[MX Records]]></category>
		<category><![CDATA[Nameservers]]></category>
		<category><![CDATA[SERVFAIL]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[SPF]]></category>
		<category><![CDATA[TTL]]></category>
		<category><![CDATA[Zoho]]></category>
		<category><![CDATA[Zoho Mail]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=344</guid>

					<description><![CDATA[<p>Sending works, nothing comes in, and no bounce shows up anywhere. That asymmetry tells you almost everything: inbound mail is decided by MX records, not by SPF. This guide walks the inbound path in the order a sending server actually walks it, groups the real causes into five DNS failure families, and gives you the dig commands to prove which one you are looking at.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/zoho-mail-not-receiving-email/">Zoho Mail Not Receiving Email? A DNS Troubleshooting Guide</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket almost always reads the same way. &#8220;We can send email fine, but nothing is coming in.&#8221; Someone has been waiting two days on a signed contract that never arrived. No bounce landed in anyone&#8217;s inbox. No error appeared anywhere in the Zoho interface. The mail just went somewhere else.</p>



<p class="wp-block-paragraph">That asymmetry, sending works and receiving doesn&#8217;t, is the single most useful clue in the whole problem. It narrows the search enormously. Yet most guides on Zoho Mail not receiving email open by telling you to check your SPF record, which has nothing whatsoever to do with inbound delivery. People lose a full day tuning an SPF string while their MX records quietly point at a data center that has never heard of their domain.</p>



<p class="wp-block-paragraph">This guide walks the inbound path in the order a sending mail server actually walks it, groups the real causes by failure family, and gives you the exact commands to prove which one you&#8217;re looking at. Everything here applies whether your DNS lives at Cloudflare, Namecheap, GoDaddy, Route 53, or a cPanel box at InterServer or Contabo.</p>



<h2 class="wp-block-heading">Sending and receiving are two separate systems</h2>



<p class="wp-block-paragraph">This is worth internalizing before you touch a single DNS record, because it eliminates most of the internet&#8217;s advice on this topic in one move.</p>



<p class="wp-block-paragraph">When you send from Zoho, your client authenticates against Zoho&#8217;s outbound SMTP server with a username and password. Your domain&#8217;s DNS plays no part in that handshake. SPF, DKIM and DMARC records exist so that the <em>recipient&#8217;s</em> server can decide whether to trust mail claiming to be from you. They are judgements other people make about your outbound mail.</p>



<p class="wp-block-paragraph">When someone sends <em>to</em> you, none of that matters. Their server looks up your MX records, connects to whatever hostname it finds, and hands the message over. Your SPF record is never consulted. Your DKIM key is never consulted.</p>



<p class="wp-block-paragraph">So if outbound works and inbound doesn&#8217;t, the fault is in the MX lookup, the connection that follows it, or Zoho&#8217;s decision about what to do with the message once it arrives. Not SPF. There is one narrow exception, covered later, where Zoho&#8217;s own inbound spam checks reject mail because the <em>sender&#8217;s</em> SPF fails. That is a different record on a different domain.</p>



<h2 class="wp-block-heading">The inbound path, in the order it actually happens</h2>



<p class="wp-block-paragraph">Six things happen between someone hitting send and a message appearing in your Zoho inbox:</p>



<ol class="wp-block-list">
<li>The sending server splits the recipient address and takes the domain part.</li>



<li>It asks DNS for the MX records of that domain, following delegation from the root down to whichever nameservers the registry says are authoritative for you.</li>



<li>It sorts the results by preference number and takes the lowest one first.</li>



<li>It resolves that hostname to an address record.</li>



<li>It opens an SMTP connection and offers the message with MAIL FROM and RCPT TO.</li>



<li>The receiving server decides whether that recipient exists in its organization and where to file the message.</li>
</ol>



<p class="wp-block-paragraph">Steps two through four are pure DNS. Step five is network. Step six is Zoho configuration. Diagnosing this well is mostly a matter of finding out which step you&#8217;re stuck on instead of guessing.</p>



<h2 class="wp-block-heading">Five DNS failure families behind Zoho Mail not receiving email</h2>



<p class="wp-block-paragraph">Nearly every inbound failure I&#8217;ve seen falls into one of these. They present almost identically from the user&#8217;s chair, which is why guessing goes badly.</p>



<h3 class="wp-block-heading">1. You&#8217;re editing records at a provider that isn&#8217;t authoritative</h3>



<p class="wp-block-paragraph">This is the most common one and the most frustrating, because the records look perfect in the control panel you&#8217;re staring at. They&#8217;re just not the records the internet reads.</p>



<p class="wp-block-paragraph">It happens whenever the domain is registered in one place and the nameservers point somewhere else. You buy at Namecheap, delegate to Cloudflare, then add MX records back in the Namecheap DNS panel out of habit. Or a previous developer moved DNS to a hosting account you no longer log into. The registrar panel accepts your edits and stores them faithfully. Nobody queries them.</p>



<p class="wp-block-paragraph">Find out where authority actually sits:</p>



<pre class="wp-block-code"><code># Which nameservers does the parent zone delegate to?
dig +short NS example.com

# What do those nameservers say about your MX, directly?
dig MX example.com @ns1.yourprovider.com +norecurse

# Watch the full delegation chain from the root
dig +trace MX example.com</code></pre>



<p class="wp-block-paragraph">The <code>+trace</code> output is the one to read carefully. It shows each referral step, so you can see exactly which nameserver set the world is being pointed at. If those names don&#8217;t match the provider where you&#8217;ve been editing, you&#8217;ve found your problem and the fix takes thirty seconds.</p>



<h3 class="wp-block-heading">2. Correct-looking MX records for the wrong Zoho data center</h3>



<p class="wp-block-paragraph">This is the failure mode that stays invisible longest, and it&#8217;s the reason copying MX values off a random blog post is dangerous.</p>



<p class="wp-block-paragraph">Zoho runs regional data centers, and your account lives in exactly one of them. The mail hostnames differ by region: the international service uses <code>.com</code> hostnames, and the European and Indian deployments use their own regional equivalents. Your domain&#8217;s mailboxes exist only in the data center where your organization was created.</p>



<p class="wp-block-paragraph">Point your MX at the wrong region and everything looks healthy. The records resolve. The hostnames are genuine Zoho servers. A lookup tool will show green. But mail arrives at infrastructure that has no record of your domain, and senders get a rejection saying the address doesn&#8217;t exist, or nothing at all.</p>



<p class="wp-block-paragraph">For the international service, Zoho&#8217;s documentation specifies three records, in this order of preference:</p>



<pre class="wp-block-code"><code>Priority 10   mx.zoho.com
Priority 20   mx2.zoho.com
Priority 50   mx3.zoho.com</code></pre>



<p class="wp-block-paragraph">Do not take that as gospel for your account. The authoritative source is the DNS Mapping section of your own Zoho Mail Admin Console, which shows the exact hostnames for the data center your organization sits in. Read them there, copy them from there. If you&#8217;re unsure which region you&#8217;re on, the domain in the URL when you log into the admin console tells you.</p>



<p class="wp-block-paragraph">Verify what the world sees, not what your panel shows:</p>



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

# Confirm the target hostname itself resolves
dig +short mx.zoho.com</code></pre>



<h3 class="wp-block-heading">3. Leftover MX records from whatever came before</h3>



<p class="wp-block-paragraph">MX preference is not a fallback list in the sense most people assume. A sending server tries the lowest number first, and if that server accepts the message, the transaction is over. It never touches the others.</p>



<p class="wp-block-paragraph">So a stale record from your old host sitting at priority 0 or 5 doesn&#8217;t compete with Zoho. It wins outright, every time. The mail is delivered successfully, to a mailbox nobody checks, on a server that may still be quietly accepting for your domain. The sender sees no error. You see nothing at all.</p>



<p class="wp-block-paragraph">This is also why partial migrations bite. Two providers both accepting mail for one domain means your messages split between them based on preference and retry timing, which looks like random message loss and is nearly impossible to reason about from the inside.</p>



<p class="wp-block-paragraph">The rule is simple: after cutover, the only MX records in the zone should be Zoho&#8217;s. Delete everything else, including records for subdomains you forgot about. Check the full answer rather than a truncated view:</p>



<pre class="wp-block-code"><code>dig MX example.com +noall +answer</code></pre>



<h3 class="wp-block-heading">4. A CNAME sitting where the MX records live</h3>



<p class="wp-block-paragraph">Two related problems here, and both are structural rather than typos.</p>



<p class="wp-block-paragraph">The first: an MX record must point to a hostname with an address record. Pointing an MX at a name that is itself a CNAME is forbidden by the DNS specifications, and Zoho documents this explicitly. Some resolvers cope, others don&#8217;t, and the ones that don&#8217;t will fail intermittently in ways that look like a network problem.</p>



<p class="wp-block-paragraph">The second: a CNAME at the zone apex. Classic DNS forbids a CNAME coexisting with any other record at the same name, and MX records at the apex are exactly that. Providers like Cloudflare work around this with CNAME flattening, which synthesizes an address record and does keep your MX records functional. Providers with a naive implementation may shadow or drop them. If someone added an apex CNAME to point the bare domain at a hosting platform or a site builder, that&#8217;s worth checking early.</p>



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



<p class="wp-block-paragraph">One thing that is <em>not</em> a problem, despite frequent claims otherwise: Cloudflare&#8217;s orange-cloud proxy setting. Proxying applies to HTTP traffic on address records. It has no effect on MX records, and toggling it grey will not fix inbound mail.</p>



<h3 class="wp-block-heading">5. DNSSEC left broken after a nameserver change</h3>



<p class="wp-block-paragraph">Less common, but spectacular when it happens, because it takes the entire domain down rather than just mail.</p>



<p class="wp-block-paragraph">If DNSSEC was enabled and the DS record at the registry no longer matches the signing keys at your current DNS provider, validating resolvers refuse to return any answer at all. They return SERVFAIL. Non-validating resolvers still work, which is why the site loads fine for you and mail from major providers, who mostly validate, vanishes.</p>



<p class="wp-block-paragraph">The test is a one-liner. <code>+cd</code> disables validation checking:</p>



<pre class="wp-block-code"><code># Fails with SERVFAIL if validation is broken
dig MX example.com

# Same query, validation bypassed
dig +cd MX example.com</code></pre>



<p class="wp-block-paragraph">If the second command returns your records and the first doesn&#8217;t, you have a DNSSEC mismatch. The fix is at your registrar: either update the DS record to match your current provider&#8217;s keys, or remove DNSSEC entirely, confirm mail flows, and re-enable it properly afterwards.</p>



<h2 class="wp-block-heading">When DNS is clean and mail still doesn&#8217;t arrive</h2>



<p class="wp-block-paragraph">If your MX records resolve correctly to the right data center and nothing else is in the zone, DNS has done its job. The remaining causes sit on either side of it.</p>



<h3 class="wp-block-heading">Your own server is intercepting the mail</h3>



<p class="wp-block-paragraph">This catches people running a site on cPanel or DirectAdmin, and it&#8217;s genuinely counterintuitive. Control panels have an email routing setting per domain, typically offering local, remote, or automatic delivery. If it&#8217;s set to local mail exchanger, the mail server on that box delivers messages for your domain into its own accounts and never performs an MX lookup.</p>



<p class="wp-block-paragraph">The effect is narrow but confusing: external senders reach Zoho normally, while anything sent from a script or a form <em>on that server</em> goes to a local mailbox instead. Contact form submissions disappear while ordinary email works. Set routing to remote mail exchanger on any host where the site lives but the mailboxes don&#8217;t.</p>



<h3 class="wp-block-heading">The address doesn&#8217;t exist in your Zoho organization</h3>



<p class="wp-block-paragraph">Zoho will only accept mail for addresses it knows about. A user account, an alias on a user, or a group. If someone hands out an address that was never created, Zoho rejects it and the sender gets an address-not-found bounce that they may not think to forward to you.</p>



<p class="wp-block-paragraph">Zoho supports a catch-all address for exactly this, and it&#8217;s worth setting one during a migration so misaddressed mail lands somewhere recoverable rather than bouncing into the void.</p>



<h3 class="wp-block-heading">Zoho accepted it and quarantined it</h3>



<p class="wp-block-paragraph">Under Security and Compliance in the Admin Console, Zoho lets you decide what happens to mail that fails SPF, DKIM, DMARC or DNSBL checks. The available actions include delivering it, quarantining it, or rejecting it outright, with or without a bounce.</p>



<p class="wp-block-paragraph">Set to reject-without-bounce, a sender whose own SPF is slightly wrong gets silently dropped, and neither party learns anything. This is the narrow SPF connection I mentioned earlier: it&#8217;s the sender&#8217;s SPF being judged, not yours. Check the incoming quarantine before concluding mail never arrived, and check the organization blocked list while you&#8217;re there, since a blocked domain or TLD produces exactly the same silence.</p>



<h3 class="wp-block-heading">The domain lapsed or lost verification</h3>



<p class="wp-block-paragraph">If a domain expires, its DNS stops resolving and every associated record becomes unreachable, which also invalidates the domain verification Zoho performed. Zoho&#8217;s documentation notes that after renewal you have to reconfigure MX records as part of reverification. Renewing the domain alone doesn&#8217;t restore mail.</p>



<h2 class="wp-block-heading">A troubleshooting sequence that ends in an answer</h2>



<p class="wp-block-paragraph">Work these in order. Each step either clears a layer or hands you the cause, so you never end up changing three things at once and losing track of which one worked.</p>



<ol class="wp-block-list">
<li><strong>Confirm delegation.</strong> Run <code>dig +short NS example.com</code> and check it matches the provider you&#8217;re editing. If not, stop here and fix that first.</li>



<li><strong>Read the MX records from an authoritative nameserver.</strong> Use <code>dig MX example.com @ns1.yourprovider.com</code> so you&#8217;re seeing the source of truth rather than a cached copy.</li>



<li><strong>Compare against the Admin Console.</strong> Open the DNS Mapping page in Zoho Mail and match hostnames character for character, including the regional suffix.</li>



<li><strong>Confirm nothing else is in the answer.</strong> Any non-Zoho MX record is a suspect, especially one with a lower preference number.</li>



<li><strong>Test resolution from a public resolver.</strong> Try <code>dig MX example.com @1.1.1.1</code> and <code>@8.8.8.8</code>. Differences between them mean you&#8217;re mid-propagation and waiting is the fix.</li>



<li><strong>Rule out DNSSEC.</strong> Compare <code>dig MX example.com</code> against <code>dig +cd MX example.com</code>.</li>



<li><strong>Prove the SMTP conversation.</strong> Send a real test and watch what the server says.</li>



<li><strong>Check quarantine and blocked lists</strong> in the Zoho Admin Console before concluding anything was lost.</li>
</ol>



<p class="wp-block-paragraph">For step seven, <code>swaks</code> is the cleanest tool. It speaks SMTP for you and prints the full exchange, so you see the actual response code rather than guessing from a bounce message:</p>



<pre class="wp-block-code"><code># Stop after RCPT TO so no message is actually delivered
swaks --to you@example.com --server mx.zoho.com --quit-after RCPT</code></pre>



<p class="wp-block-paragraph">A 250 after RCPT TO means Zoho recognises the address and would accept the mail. A 550 means it doesn&#8217;t know that recipient, which points straight at a missing mailbox or the wrong data center. A connection timeout usually isn&#8217;t Zoho at all: many residential and cloud providers block outbound port 25 by default, so run this from a machine you know has egress on 25. If you only have a laptop on a consumer connection, testing through a VPN endpoint that permits SMTP, or from a small VPS, avoids chasing a phantom.</p>



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



<ul class="wp-block-list">
<li><strong>Fixing SPF to solve an inbound problem.</strong> Your SPF record has no role in receiving mail. If you can send but not receive, it is not SPF.</li>



<li><strong>Copying MX values from a tutorial.</strong> Regional deployments have different hostnames. Use your own Admin Console every time.</li>



<li><strong>Adding Zoho&#8217;s records without removing the old ones.</strong> A lower preference number beats Zoho outright and nothing warns you.</li>



<li><strong>Trusting the DNS panel over a query.</strong> A saved record and a published record are different things. Query it.</li>



<li><strong>Cutting MX over before creating the mailboxes.</strong> Every address that doesn&#8217;t exist yet bounces, and those messages are gone.</li>



<li><strong>Blaming propagation on day three.</strong> Propagation is bounded by the TTL that was in effect before the change. If it&#8217;s been longer than that, something is genuinely wrong.</li>



<li><strong>Trailing dot confusion.</strong> Some panels want <code>mx.zoho.com.</code> with the dot, others append the zone themselves and turn <code>mx.zoho.com</code> into an unresolvable name. Always confirm with a query afterwards.</li>
</ul>



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



<ul class="wp-block-list">
<li><strong>Drop the TTL before you migrate.</strong> Lower the MX TTL to a few minutes a day ahead of cutover, do the change, then raise it back. This turns a rollback from an overnight wait into a coffee break.</li>



<li><strong>Create every mailbox, alias and group before touching MX.</strong> Zoho&#8217;s own guidance is to configure users first and change MX last.</li>



<li><strong>Set a catch-all during the transition.</strong> It converts &#8220;we lost an email&#8221; into &#8220;it&#8217;s in the catch-all mailbox&#8221;.</li>



<li><strong>Keep DNS in one place.</strong> One authoritative provider, one panel, no ambiguity about where records live.</li>



<li><strong>Monitor the MX record itself.</strong> An uptime or synthetic monitoring service that checks DNS answers, or a scheduled <code>dig</code> comparison in your own pipeline, catches an accidental zone edit before a client does.</li>



<li><strong>Enable DKIM and set DMARC after inbound is confirmed working.</strong> They matter for your outbound reputation. Doing them first just adds variables while you&#8217;re debugging.</li>



<li><strong>Keep a copy of the zone.</strong> Export it before major changes. Reconstructing a zone from memory during an outage is miserable.</li>
</ul>



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



<h3 class="wp-block-heading">Why can I send from Zoho Mail but not receive?</h3>



<p class="wp-block-paragraph">Because sending authenticates directly against Zoho&#8217;s outbound servers and doesn&#8217;t consult your domain&#8217;s DNS, while receiving depends entirely on your MX records. Working outbound with broken inbound is a strong signal that the problem is an MX record, a nameserver delegation issue, or a leftover record from a previous provider.</p>



<h3 class="wp-block-heading">How long should MX changes take to work?</h3>



<p class="wp-block-paragraph">Propagation is governed by the TTL that was set on the old record, not by any fixed schedule. If the previous TTL was one hour, resolvers holding a cached copy will refresh within an hour. If it was set to a day, expect a day. Nothing you do afterwards speeds up a cache that has already been populated.</p>



<h3 class="wp-block-heading">Do I need all three Zoho MX records?</h3>



<p class="wp-block-paragraph">Mail will flow with only the primary, but the backups exist so that senders have somewhere to deliver if the primary is unreachable. Without them, sending servers queue and retry, which delays delivery and eventually bounces. Add the full set your Admin Console lists.</p>



<h3 class="wp-block-heading">Does SPF affect whether I receive email?</h3>



<p class="wp-block-paragraph">Your own SPF record does not. It&#8217;s evaluated by servers receiving mail from you. The one indirect connection is that Zoho evaluates the <em>sender&#8217;s</em> SPF on incoming mail, and if your Admin Console is set to reject or quarantine on SPF failure, legitimate mail from badly configured senders can disappear.</p>



<h3 class="wp-block-heading">Why do lookup tools say my MX records are fine when mail still fails?</h3>



<p class="wp-block-paragraph">Lookup tools verify that records exist and resolve. They can&#8217;t know which Zoho data center your organization lives in, whether the mailbox has been created, or whether a message is sitting in quarantine. A green result rules out one layer, not all of them.</p>



<h3 class="wp-block-heading">Can I run Zoho Mail alongside another mail provider on one domain?</h3>



<p class="wp-block-paragraph">Not by simply listing both sets of MX records. That splits mail unpredictably. Zoho does offer routing arrangements designed for gradual migration, configured on the Zoho side rather than by stacking MX records in DNS. If you need a genuine split, use the supported mechanism.</p>



<h3 class="wp-block-heading">My contact form emails vanish but normal email works. Why?</h3>



<p class="wp-block-paragraph">Almost always the control panel email routing setting on the server hosting the site. If it&#8217;s on local mail exchanger, mail generated on that machine is delivered into a local account rather than sent out to Zoho. Switch it to remote.</p>



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



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



<p class="wp-block-paragraph">If you take one thing from this: when Zoho Mail is not receiving email but sending works, stop looking at authentication records. Inbound mail is decided by your MX records, and those records have to be published by the nameservers that are actually authoritative for your domain, pointing at the data center your Zoho organization lives in, with nothing else in the answer competing with them.</p>



<p class="wp-block-paragraph">Query, don&#8217;t assume. Read the answer from the authoritative nameserver rather than the panel, compare it against your own Admin Console instead of a tutorial, then work down through SMTP and Zoho&#8217;s own quarantine. Almost every case resolves in that order, and the ones that don&#8217;t are usually a mailbox that was never created in the first place.</p>



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



<h2 class="wp-block-heading">Need someone to untangle your mail DNS?</h2>



<p class="wp-block-paragraph">I work on DNS and mail delivery problems like this regularly, usually for people who have already tried three fixes and made things harder to reason about. Things I can help with:</p>



<ul class="wp-block-list">
<li>Diagnosing inbound mail failures end to end, from delegation through SMTP response codes to Zoho&#8217;s quarantine</li>



<li>Planning and executing a mail migration to Zoho with a TTL schedule and a rollback path that actually works</li>



<li>Auditing and cleaning up a zone that several people have edited over the years</li>



<li>Setting up SPF, DKIM and DMARC properly once inbound is stable, including staged DMARC enforcement</li>



<li>Fixing DNSSEC mismatches after a nameserver move without taking the domain down again</li>



<li>Adding DNS and mail-flow monitoring so the next zone edit doesn&#8217;t go unnoticed for a week</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>dig +trace MX yourdomain.com</code> and a rejection message from a sender, and I can usually tell you where it&#8217;s breaking before we&#8217;ve agreed on anything.</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/zoho-mail-not-receiving-email/">Zoho Mail Not Receiving Email? A DNS Troubleshooting Guide</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Bulk WordPress Migration: Moving 20 Sites to a New VPS in One Weekend</title>
		<link>https://john-nessime.com/blog/technical-guides/bulk-wordpress-migration-vps/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 29 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Hosting & Infrastructure]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Cutover Planning]]></category>
		<category><![CDATA[Decommissioning]]></category>
		<category><![CDATA[DirectAdmin]]></category>
		<category><![CDATA[DNS Migration]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[Incremental Sync]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[MX Records]]></category>
		<category><![CDATA[Rollback Plan]]></category>
		<category><![CDATA[rsync]]></category>
		<category><![CDATA[search-replace]]></category>
		<category><![CDATA[Server Provisioning]]></category>
		<category><![CDATA[SPF]]></category>
		<category><![CDATA[TTL]]></category>
		<category><![CDATA[VPS]]></category>
		<category><![CDATA[Website Migration]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WordPress Migration]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<category><![CDATA[WP-Cron]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=301</guid>

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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/bulk-wordpress-migration-vps/">Bulk WordPress Migration: Moving 20 Sites to a New VPS in One Weekend</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Business Email DNS Done Right: MX, SPF, DKIM and DMARC</title>
		<link>https://john-nessime.com/blog/technical-guides/business-email-dns-mx-spf-dkim-dmarc/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 23 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Networking]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Anti-Spoofing]]></category>
		<category><![CDATA[Business Email]]></category>
		<category><![CDATA[dig]]></category>
		<category><![CDATA[DKIM]]></category>
		<category><![CDATA[DMARC]]></category>
		<category><![CDATA[DMARC Reporting]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[DNS Records]]></category>
		<category><![CDATA[Domain Configuration]]></category>
		<category><![CDATA[Email Authentication]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[MX Records]]></category>
		<category><![CDATA[Null MX]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[SPF]]></category>
		<category><![CDATA[TTL]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=438</guid>

					<description><![CDATA[<p>Your domain has all four records. Every free checker shows green. It is still trivially spoofable, because publishing a record and enforcing a policy are different acts. A working guide to business email DNS: what MX, SPF, DKIM and DMARC each actually protect, the SPF lookup budget and the void-lookup limit nobody watches, why DKIM alignment decides whether you can ever reach p=reject, the domain architecture that stops the problem recurring, and a rollout order that gets you to enforcement without losing legitimate mail.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/business-email-dns-mx-spf-dkim-dmarc/">Business Email DNS Done Right: MX, 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 finance manager forwards you a message and asks how it got through. It appears to come from the CEO, it carries the company domain in the From line, and it asks for a bank detail change on an invoice that is genuinely outstanding.</p>



<p class="wp-block-paragraph">So you check the zone. There is an MX record. There is an SPF record. There is a DKIM key published under a selector. There is a DMARC record. You paste the domain into three free checkers and every one of them lights up green.</p>



<p class="wp-block-paragraph">Everything is configured. The domain is still trivially spoofable.</p>



<p class="wp-block-paragraph">This is the failure mode that makes business email DNS worth writing about. It is not that people forget to publish records. It is that publishing a record and enforcing a policy are different acts, the checkers reward the first one, and nothing anywhere in DNS will tell you that you stopped halfway.</p>



<p class="wp-block-paragraph">What follows is what each of the four records actually does, what breaks when it is wrong, the domain architecture decision that stops the problem recurring, and an order of operations that gets you to enforcement without losing legitimate mail on the way.</p>



<h2 class="wp-block-heading">The two jobs your business email DNS actually does</h2>



<p class="wp-block-paragraph">Almost every confused email ticket traces back to one misunderstanding: people treat these four records as a single feature called &#8220;email setup.&#8221; They are two unrelated systems that happen to share a zone file.</p>



<ul class="wp-block-list"><li><strong>Receiving mail.</strong> This is MX, and only MX. Nothing else in the set participates.</li><li><strong>Authorizing senders.</strong> This is SPF, DKIM and DMARC. None of them affect whether a single message reaches your mailbox.</li></ul>



<p class="wp-block-paragraph">The consequence is blunt. If inbound mail is not arriving, editing SPF cannot help and never will. If your domain is being spoofed, a perfect MX configuration contributes nothing. It is common to see an afternoon spent rewriting an SPF record for a problem that lived entirely in a mail routing rule.</p>



<p class="wp-block-paragraph">A third job hides inside the second, and it is the one people skip. SPF and DKIM only produce evidence: this server was authorized, this content was signed by this domain. Neither tells a receiving server what to do when the evidence is missing. Only DMARC does, through its policy tag, and that tag is the single enforcement dial in the whole arrangement.</p>



<h2 class="wp-block-heading">MX: the only record inbound delivery depends on</h2>



<p class="wp-block-paragraph">An MX record carries exactly two values: a preference number and a hostname. Lower preference numbers are tried first. Equal preference numbers let a sender spread load across the listed hosts, which is how most hosted mail providers publish theirs.</p>



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



<p class="wp-block-paragraph">The hostname side has two constraints panels will happily let you violate. The target must be a name resolving to A or AAAA records, not an IP typed into the field, and it must not point at a CNAME. Some panels accept an IP and quietly invent a hostname behind the scenes, which works until you move providers and find the record you thought you published is not the one that exists.</p>



<p class="wp-block-paragraph">If a domain has no MX record at all, the SMTP specification tells senders to fall back to its A or AAAA address. On a business domain that is your web server. The connection times out or is refused, and the sender queues and retries for days before anyone sees a bounce. The mail is not rejected; it is late, sometimes by a working week.</p>



<p class="wp-block-paragraph">And subdomains do not inherit MX records from the parent. If <code>example.com</code> has MX records, <code>mail.example.com</code> does not. It falls back to its own A record, or to nothing.</p>



<h3 class="wp-block-heading">Null MX for domains that should never receive mail</h3>



<p class="wp-block-paragraph">Most businesses own more domains than they use for mail: typo-squat defenses, old brand names, a marketing microsite, the domain from the company they acquired. RFC 7505 defines a way to say &#8220;this domain accepts no mail&#8221; explicitly, using a single MX record with preference 0 and a target of a lone dot.</p>



<pre class="wp-block-code"><code>example-parked.com.  3600  IN  MX  0  .</code></pre>



<p class="wp-block-paragraph">A conforming sender sees this and rejects immediately instead of falling back to the A record and retrying for days. The bigger benefit is backscatter: when someone forges a return address at a domain of yours, bounce messages get aimed at it. A null MX turns those into an instant rejection at the sender&#8217;s end.</p>



<p class="wp-block-paragraph">One rule attached to this: a domain advertising a null MX must not publish any other MX records. It is the whole set or nothing.</p>



<p class="wp-block-paragraph">One operational note: drop MX TTLs to a few minutes before a mail migration and raise them afterwards. A low TTL costs a little query volume. A high TTL during a cutover costs you mail delivered to a server you already decommissioned, with no error anywhere to tell you. Per-record TTL control and fast propagation matter here, which is why I would rather do a cutover on something like Cloudflare than a registrar&#8217;s default panel.</p>



<h2 class="wp-block-heading">SPF: an authorization list with a hard budget</h2>



<p class="wp-block-paragraph">SPF is a TXT record at the domain apex that begins with <code>v=spf1</code> and lists the systems allowed to send with your domain in the envelope sender. Publish two of them and you have published none: the result is a permanent error, which receivers treat as a failure.</p>



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



<p class="wp-block-paragraph">The constraint everyone eventually hits is the lookup budget. Evaluation is capped at ten DNS-querying mechanisms. The terms that count are <code>include</code>, <code>a</code>, <code>mx</code>, <code>ptr</code>, <code>exists</code> and <code>redirect</code>, and the count follows nested includes all the way down, so one vendor <code>include:</code> can carry four lookups inside it. Literal <code>ip4</code> and <code>ip6</code> ranges cost nothing, and neither does <code>all</code>.</p>



<p class="wp-block-paragraph">Exceed ten and evaluation stops with a permanent error. It does not fail open. It does not keep counting and pass anyway.</p>



<p class="wp-block-paragraph">There is a second, quieter limit: void lookups, meaning queries that return nothing. Implementations are told to cap those at two. This is the one that bites long after you stop paying attention, because the trigger is a vendor you cancelled whose <code>include:</code> target eventually stopped resolving. Your record did not change. Your mail started failing anyway.</p>



<p class="wp-block-paragraph">Over budget, the honest options are to drop vendors you no longer use, replace lookup-heavy mechanisms with the literal IP ranges they resolve to, or move senders onto their own subdomains so each gets a fresh budget. Flattening services that resolve includes and keep the record updated do work, but understand the trade: you have taken on a dependency that must track your vendors&#8217; IP changes, and cloud senders rotate ranges.</p>



<h3 class="wp-block-heading">Why hard fail is not automatically the safer choice</h3>



<p class="wp-block-paragraph">SPF records end with a qualifier on <code>all</code>. Soft fail is <code>~all</code>; hard fail is <code>-all</code>. The instinct is that hard fail is the stricter, more secure choice, and it is, but the DMARC specification calls out a specific cost that is easy to miss.</p>



<p class="wp-block-paragraph">SPF is evaluated early in the SMTP conversation. A hard fail can get a message rejected before the message body is ever transmitted, which means before the From header is visible and before DMARC is evaluated at all. A message that would have passed DMARC on an aligned DKIM signature can be rejected purely on SPF.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow"><p>Messages rejected that early never reach the DATA phase, so they never appear in your DMARC aggregate reports. You lose the failure and the evidence of the failure in the same move.</p></blockquote>



<p class="wp-block-paragraph">That is the argument for staying on <code>~all</code> while you are still discovering who sends as you, and moving to <code>-all</code> once your reports have gone quiet. On a parked domain with no legitimate senders at all, publish <code>v=spf1 -all</code> immediately; there is nothing to discover.</p>



<p class="wp-block-paragraph">One more thing SPF does not do, and this is the root of most DMARC confusion. SPF authenticates the envelope sender, the address in the SMTP <code>MAIL FROM</code> command. Your recipients never see that address. They see the From header, which SPF does not check at all.</p>



<h2 class="wp-block-heading">DKIM: the signature that survives a forward</h2>



<p class="wp-block-paragraph">DKIM adds a cryptographic signature over selected headers and the message body. The public key lives in DNS under a selector, in the form <code>selector._domainkey.example.com</code>, and the signature header names both the selector and a signing domain in its <code>d=</code> tag.</p>



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



<p class="wp-block-paragraph">That <code>d=</code> value is the part that matters and the part that gets ignored. A message can carry a perfectly valid signature from your provider&#8217;s own domain. It verifies. It proves nothing about you, because the identity it asserts is theirs. Completing your provider&#8217;s domain authentication step, the one that has you publish records under your own zone, is what moves <code>d=</code> onto your domain.</p>



<h3 class="wp-block-heading">Algorithms and key size</h3>



<p class="wp-block-paragraph">The rules here are settled. Signers must use rsa-sha256. The older rsa-sha1 must not be used for signing or verifying, and signatures using it fail permanently. Keys must be at least 1024 bits and should be at least 2048. Verifiers are required to reject anything under 1024 outright.</p>



<p class="wp-block-paragraph">A useful field heuristic follows from a DNS quirk. A single TXT character string maxes out around 255 characters, and a 2048-bit RSA key does not fit in one. So if your DKIM record comes back from <code>dig</code> as one quoted string, it is probably 1024-bit; split into two concatenated strings, probably 2048. A hint rather than proof, but it takes two seconds.</p>



<p class="wp-block-paragraph">An Ed25519 signing algorithm is also defined for DKIM, with compact keys and good cryptographic properties, but support across platforms and verifiers is still uneven. RSA with SHA-256 remains the safe universal choice unless you sign with both.</p>



<h3 class="wp-block-heading">Rotating a key without breaking anything</h3>



<p class="wp-block-paragraph">Never edit a DKIM key in place. Mail already in flight, in a queue, or sitting in a mailing list&#8217;s outbound spool was signed with the old key, and overwriting the record invalidates all of it retroactively.</p>



<ol class="wp-block-list"><li>Publish the new key under a new selector name and confirm it resolves.</li><li>Switch the signing platform to the new selector.</li><li>Wait out the longest plausible delivery delay, which is days rather than hours if mailing lists are involved.</li><li>Remove the old selector record.</li></ol>



<p class="wp-block-paragraph">DKIM has one property SPF does not, and it decides your architecture later. A signature travels with the message. When mail passes through a forwarder, a role alias or a mailing list, the connecting IP is no longer yours and SPF fails; the DKIM signature usually still verifies. This is why the DMARC specification states that domains publishing a reject policy must not rely on SPF alone and must apply valid DKIM signatures.</p>



<h2 class="wp-block-heading">DMARC: connecting the visible domain to the proof</h2>



<p class="wp-block-paragraph">DMARC is a TXT record at <code>_dmarc.example.com</code>. It asks one question that neither SPF nor DKIM asks: does the domain your recipient can actually see match the domain that proved itself?</p>



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



<p class="wp-block-paragraph">That match is called alignment. Relaxed alignment, the default, accepts any domain sharing the same organizational domain, so <code>mail.example.com</code> aligns with <code>example.com</code>. Strict requires them to be identical. A message passes DMARC if either SPF or DKIM passes <em>and</em> the domain that passed aligns with the From header. One aligned mechanism is enough. Both passing while neither aligns is a fail, and that is the most common cause of &#8220;but all my checkers are green.&#8221;</p>



<h3 class="wp-block-heading">The tags worth knowing</h3>



<ul class="wp-block-list"><li><code>p</code> is the policy for the domain itself: <code>none</code>, <code>quarantine</code> or <code>reject</code>. Omit it and the record is treated as <code>p=none</code>.</li><li><code>sp</code> sets a separate policy for existing subdomains.</li><li><code>np</code> sets a policy for subdomains that do not exist in DNS at all. This is the anti-spoofing tag most zones are missing.</li><li><code>rua</code> is where aggregate reports are sent. Without it you get no reports, and without reports you are guessing.</li><li><code>adkim</code> and <code>aspf</code> switch the two alignment modes from relaxed to strict.</li><li><code>t=y</code> is test mode. It tells receivers to apply one level below your stated policy, so a record saying reject behaves as quarantine, and quarantine behaves as none.</li></ul>



<p class="wp-block-paragraph">The DMARC specification has been rewritten and promoted onto the IETF standards track as RFC 9989, with aggregate and failure reporting split into RFC 9990 and RFC 9991, obsoleting the original RFC 7489. Existing records keep working and still begin with <code>v=DMARC1</code>, so nothing is urgent. Three things are worth acting on at your next zone edit.</p>



<p class="wp-block-paragraph">The <code>pct</code>, <code>rf</code> and <code>ri</code> tags are gone. Unknown tags are ignored rather than treated as errors, so leaving them does no damage, but <code>pct</code> no longer means anything and the staged rollout it was used for is now <code>t=y</code>.</p>



<p class="wp-block-paragraph">The Public Suffix List is no longer how receivers determine your organizational domain. They now walk up the DNS hierarchy looking for a DMARC record at each level, capped at eight queries. For a two-label domain this changes nothing. For a deep subdomain hierarchy it can resolve differently than the old method did, and a name deeper than eight labels will never have its policy record found by the walk. If you run a complex namespace, publish an explicit record at the exact name rather than relying on inheritance.</p>



<p class="wp-block-paragraph">And if two DMARC TXT records are returned for the same name, both are discarded. A duplicate is not a merge or a tiebreak. It is the same as having none.</p>



<h3 class="wp-block-heading">Reading the reports</h3>



<p class="wp-block-paragraph">Aggregate reports arrive as gzipped XML, typically once a day per reporting provider. They are technically human-readable and practically not, especially once a dozen providers are reporting on a domain with several sending services.</p>



<p class="wp-block-paragraph">Two honest choices. Parse them yourself, which is a small satisfying project that gives you exactly the view you want. Or point <code>rua</code> at a hosted processor; Dmarcian, Valimail and PowerDMARC all do this competently, with entry tiers sufficient for one domain. The trade is that you are sending metadata about your mail flows to a third party and inheriting their retention terms. Read those terms first in a regulated environment.</p>



<h2 class="wp-block-heading">Domain architecture: the decision that stops this recurring</h2>



<p class="wp-block-paragraph">Everything above is records. This part is structure, and it is what separates a zone that stays correct from one that quietly rots.</p>



<p class="wp-block-paragraph">Put each sending stream on its own subdomain. People mail from the apex. Marketing sends from something like <code>news.example.com</code>. Transactional and application mail sends from <code>mail.example.com</code> or similar. Each subdomain gets its own SPF record, its own DKIM selectors, and its own DMARC record if you want a different policy there.</p>



<ul class="wp-block-list"><li>Each subdomain gets a fresh ten-lookup SPF budget, which is usually what forced the decision in the first place.</li><li>A marketing platform that gets breached or blocklisted does not drag your invoices and password resets down with it.</li><li>Reputation is scoped. Bulk sending and one-to-one business mail get judged separately, which is what you want, because they behave nothing alike.</li><li>You can hold the apex at a strict policy while a subdomain is still being brought into line.</li></ul>



<p class="wp-block-paragraph">The cost is more records to maintain and one more thing to explain to whoever inherits the zone. It is worth it the first time a vendor migration happens and you only have to touch one subdomain.</p>



<p class="wp-block-paragraph">Then close the gaps. Every domain that never sends and never receives should carry a null MX, an SPF record of <code>v=spf1 -all</code>, and DMARC at <code>p=reject</code>. Three records, five minutes, and the domain leaves the pool of things abusable in your name. Add <code>np=reject</code> on live domains too, because an attacker does not need a subdomain to exist in order to put it in a From header.</p>



<h2 class="wp-block-heading">Rolling it out without losing mail</h2>



<p class="wp-block-paragraph">The order matters, because each step depends on evidence produced by the one before it.</p>



<ol class="wp-block-list"><li><strong>Inventory every sender.</strong> Mailbox provider, CRM, helpdesk, billing system, marketing platform, monitoring alerts, and the tool a department signed up for without telling anyone. This list is always longer than expected and it is the highest-value step here.</li><li><strong>Fix MX first,</strong> with a low TTL if you are migrating. Inbound is what people notice within minutes.</li><li><strong>Publish SPF within budget,</strong> ending in <code>~all</code> for now. Count lookups before publishing, not after mail fails.</li><li><strong>Get DKIM signing on your own domain</strong> for every stream. Verify <code>d=</code> in the headers of a real received message rather than trusting a dashboard.</li><li><strong>Publish DMARC at <code>p=none</code> with a <code>rua</code> address.</strong> This changes nothing about delivery and starts the flow of evidence.</li><li><strong>Read reports for several weeks.</strong> Longer if you have quarterly patterns like invoicing runs or annual renewals, which is exactly the mail you cannot afford to break.</li><li><strong>Move to <code>p=quarantine</code></strong> once every remaining failure is one you recognize as spoofing. Use <code>t=y</code> for a softer first step.</li><li><strong>Move to <code>p=reject</code>,</strong> confirming DKIM is aligned on every stream first, then tighten SPF to <code>-all</code>.</li></ol>



<p class="wp-block-paragraph">Step six is the one people compress, and it is the only step that generates the information the last two depend on. Skipping it means going to enforcement blind and finding out which stream you broke from a colleague rather than from a report.</p>



<p class="wp-block-paragraph">Steps one and six are also the two that never finish. Keep the sender inventory current as vendors come and go, and keep reading reports after you reach enforcement, because a new tool bought by a department you never talk to shows up in a report long before it shows up in a conversation.</p>



<h2 class="wp-block-heading">Troubleshooting, in the order worth checking</h2>



<p class="wp-block-paragraph">When something is wrong, the first question is which of the two jobs failed. Inbound problems and spoofing problems share no diagnostic steps.</p>



<h3 class="wp-block-heading">Inbound mail is not arriving</h3>



<ol class="wp-block-list"><li>Confirm the MX records exist and resolve, and that each target has an A or AAAA record.</li><li>Query your authoritative nameservers directly rather than a resolver, so you are looking at what you published rather than what is cached.</li><li>Confirm the mail host actually answers on port 25 and offers STARTTLS.</li><li>If all three pass, the problem is inside the mail platform: routing rules, a filter, an aliasing mistake, a mailbox over quota. It is no longer DNS.</li></ol>



<pre class="wp-block-code"><code>dig +short NS example.com
dig @ns1.example-dns.net example.com MX
openssl s_client -starttls smtp -connect mail.example.com:25</code></pre>



<h3 class="wp-block-heading">Legitimate mail is being rejected or quarantined</h3>



<ol class="wp-block-list"><li>Read the SMTP rejection text. Receivers name the reason, and DMARC-related rejections usually say so in plain words.</li><li>Open the headers of a message that failed and read the authentication results. They tell you which check failed and which domain was evaluated.</li><li>Compare the <code>d=</code> value in the DKIM signature against the From domain. If they differ and share no organizational domain, alignment is your problem.</li><li>Count your SPF lookups. A permanent error there presents as a plain SPF failure with nothing in the record that looks wrong.</li><li>Check the sending IP has a valid reverse DNS record that resolves forward again. Only whoever controls the IP can set this, which means your provider if you are on shared infrastructure, or you if you run your own server.</li></ol>



<pre class="wp-block-code"><code>dig -x 203.0.113.10 +short</code></pre>



<p class="wp-block-paragraph">That reverse DNS point matters more than it looks. Major mailbox providers expect sending IPs to have valid forward and reverse DNS, so if you run your own mail server, PTR control is a hard requirement rather than a nice-to-have. Confirm it before you buy. Hosts aimed at self-hosting, InterServer and Contabo among them, expose PTR in the panel; plenty of budget instances do not, and some block port 25 outright.</p>



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



<ul class="wp-block-list"><li><strong>Treating <code>p=none</code> as done.</strong> It is a monitoring mode. It provides no protection whatsoever, and every checker will still show you a green tick.</li><li><strong>Publishing two SPF records.</strong> Usually one from the mailbox provider&#8217;s setup wizard and one from a marketing tool. The result is worse than either alone.</li><li><strong>Assuming a passing DKIM signature is your DKIM signature.</strong> Check <code>d=</code>, not the presence of a signature.</li><li><strong>Editing a DKIM key in place</strong> instead of rotating through a second selector.</li><li><strong>Leaving parked and brand domains bare.</strong> No MX, no SPF, no DMARC is an open invitation, and these domains are usually the ones nobody is monitoring.</li><li><strong>Going to <code>p=reject</code> without DKIM aligned</strong> on every stream. Forwarded mail will break, and forwarded mail is disproportionately mail from customers.</li><li><strong>Setting <code>rua</code> and never reading it.</strong> Reports you do not process are the same as reports you never requested.</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">Do I need both SPF and DKIM, or is one enough?</h3>



<p class="wp-block-paragraph">DMARC only needs one of them to pass and align. Publish both anyway. They fail in different circumstances, and having only one means any situation that breaks it breaks all your authentication at once. Major mailbox providers also expect both from higher-volume senders, and DKIM specifically is required if you intend to publish a reject policy.</p>



<h3 class="wp-block-heading">How long should I stay at p=none?</h3>



<p class="wp-block-paragraph">Long enough to see a full cycle of your sending patterns, plus time to fix what the reports reveal. For a small business with a mailbox provider and two SaaS tools, a few weeks. For an organization with quarterly billing runs and departments who buy their own software, months. The signal to move is that you recognize every remaining failure as illegitimate, not that a calendar reminder fired.</p>



<h3 class="wp-block-heading">Will DMARC stop phishing?</h3>



<p class="wp-block-paragraph">It stops exact-domain spoofing, one technique among several. It does nothing about lookalike domains, nothing about display-name spoofing where the visible name says your CEO but the address is free webmail, and nothing about a compromised mailbox sending properly authenticated mail. One control worth having, not a phishing solution.</p>



<h3 class="wp-block-heading">Does any of this affect whether I receive email?</h3>



<p class="wp-block-paragraph">No. SPF, DKIM and DMARC describe mail leaving your domain. Only MX affects mail arriving at it. The one indirect connection is that your own inbound filtering may evaluate other people&#8217;s SPF and DKIM when deciding what to do with their mail, but that is your mail platform&#8217;s behavior, not your DNS records.</p>



<h3 class="wp-block-heading">Do I need to update my records for the new DMARC RFCs?</h3>



<p class="wp-block-paragraph">Nothing breaks if you do nothing. At your next edit, drop <code>pct</code>, <code>rf</code> and <code>ri</code> if they are present, consider adding <code>np</code> to close the non-existent subdomain gap, and if you run a deep subdomain hierarchy, publish explicit records rather than relying on inheritance. Leave the rest at defaults, because the defaults are sensible.</p>



<h3 class="wp-block-heading">My mail goes to spam even though everything passes. What now?</h3>



<p class="wp-block-paragraph">Authentication and reputation are different problems. Passing DMARC tells a receiver the mail genuinely came from you; it says nothing about whether recipients want it. If authentication is clean, look at complaint rates, list hygiene, volume ramps and content. Google publishes a spam complaint ceiling and its guidance is to operate well below that ceiling, not near it.</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 take one thing from this, take the distinction between publishing and enforcing. Business email DNS gives you four records that answer four different questions, and only one of them, the DMARC policy tag, actually instructs a receiving server to do anything. Every free checker will confirm the records exist. None of them will tell you the dial is at zero.</p>



<p class="wp-block-paragraph">Get MX right so mail arrives. Keep SPF inside its lookup budget. Make DKIM sign with your own domain so <code>d=</code> aligns. Publish DMARC at <code>p=none</code> with a reporting address, read what comes back for as long as it takes, then move the dial until it reaches reject.</p>



<p class="wp-block-paragraph">And while you are in the zone, spend five minutes on the domains nobody thinks about. A null MX, an empty SPF record and a reject policy on a parked domain is the cheapest security work available to you.</p>



<h2 class="wp-block-heading">Need a second pair of eyes on your mail domain?</h2>



<p class="wp-block-paragraph">Mail DNS is unusually well suited to outside help, because the failure is almost always somewhere the domain owner cannot see it: in a header, in a report nobody parses, or in a vendor&#8217;s include chain. Things I regularly help with:</p>



<ul class="wp-block-list"><li>Auditing an existing zone across MX, SPF, DKIM and DMARC and telling you plainly what is exposed and what is fine</li><li>Getting an SPF record back under the ten-lookup limit without breaking a sender you forgot about</li><li>Planning and executing the move from <code>p=none</code> to <code>p=quarantine</code> to <code>p=reject</code>, including the sender inventory and the report reading</li><li>Setting up DMARC aggregate report collection and parsing, either self-hosted or on a processor</li><li>Splitting sending streams onto subdomains and migrating each one without a delivery gap</li><li>Locking down parked and brand-protection domains so they stop being usable in your name</li></ul>



<p class="wp-block-paragraph">If you would rather not describe the problem in the abstract, send me the output of a <code>dig</code> against your domain, or a set of headers from a message that failed, and I will tell you what I see in it.</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/business-email-dns-mx-spf-dkim-dmarc/">Business Email DNS Done Right: MX, SPF, DKIM and DMARC</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>DNS Troubleshooting: How to Find the Real Cause in Minutes, Not Hours</title>
		<link>https://john-nessime.com/blog/networking/dns-troubleshooting/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 13:50:39 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Networking]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<category><![CDATA[BIND]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[dig]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[DNS Cache]]></category>
		<category><![CDATA[DNS Records]]></category>
		<category><![CDATA[DNS Troubleshooting]]></category>
		<category><![CDATA[DNSSEC]]></category>
		<category><![CDATA[Domain Configuration]]></category>
		<category><![CDATA[Linux Networking]]></category>
		<category><![CDATA[MX Records]]></category>
		<category><![CDATA[Nameservers]]></category>
		<category><![CDATA[Network Debugging]]></category>
		<category><![CDATA[NSlookup]]></category>
		<category><![CDATA[NXDOMAIN]]></category>
		<category><![CDATA[SERVFAIL]]></category>
		<category><![CDATA[SPF]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[TTL]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=27</guid>

					<description><![CDATA[<p>Most "the site is down" tickets are really DNS problems wearing a costume. Here's the practical DNS troubleshooting workflow I use — how to read dig output, follow the delegation chain, decode SERVFAIL and NXDOMAIN, and stop blaming propagation for things that are actually caching, TTL, or DNSSEC failures.</p>
<p>The post <a href="https://john-nessime.com/blog/networking/dns-troubleshooting/">DNS Troubleshooting: How to Find the Real Cause in Minutes, Not Hours</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 messages you: &#8220;the site is down.&#8221; You open it — it loads fine on your machine. You ask them to try again, and it still fails. Nothing changed on the server. Nothing changed in the code. And somewhere in the back of your head a small voice says: <em>it&#8217;s DNS again.</em></p>



<p class="wp-block-paragraph">It usually is. Not because DNS is fragile, but because it&#8217;s the one layer where caching, delegation, and propagation all conspire to make the same domain behave differently depending on who&#8217;s asking and when. A record can be perfectly correct in your control panel and still resolve to the wrong IP for half the internet.</p>



<p class="wp-block-paragraph">This article is the DNS troubleshooting workflow I actually use — the order of checks, the commands worth memorising, how to read what they tell you, and the specific failure patterns that show up over and over. No theory dumps. Just the path from &#8220;something&#8217;s broken&#8221; to &#8220;here&#8217;s exactly which record on which server is wrong.&#8221;</p>



<h2 class="wp-block-heading">First, Get the Mental Model Right</h2>



<p class="wp-block-paragraph">You can&#8217;t debug a system you don&#8217;t have a picture of. When a browser resolves <code>example.com</code>, several independent parties are involved, and any one of them can be the liar:</p>



<ol class="wp-block-list"><li><strong>The application</strong> — browsers keep their own short-lived DNS cache, and HSTS or connection pooling can mask changes entirely.</li><li><strong>The operating system</strong> — the local stub resolver, <code>/etc/hosts</code>, <code>nscd</code>, <code>systemd-resolved</code>, or the Windows DNS Client service.</li><li><strong>The recursive resolver</strong> — your router, your ISP, or a public resolver like 1.1.1.1 or 8.8.8.8. This is where most stale answers live.</li><li><strong>The delegation chain</strong> — root servers, then the TLD servers, then whichever name servers your registrar says are authoritative.</li><li><strong>The authoritative name server</strong> — the only place where the truth actually lives.</li></ol>



<p class="wp-block-paragraph">Effective DNS troubleshooting is just working down that list and asking each party the same question directly, instead of trusting whatever the browser tells you. The moment you find two parties giving different answers, you&#8217;ve found your boundary — and the problem is between them.</p>



<h2 class="wp-block-heading">The Tools That Matter</h2>



<p class="wp-block-paragraph">You need surprisingly few. My honest ranking:</p>



<h3 class="wp-block-heading">dig — the one you should learn properly</h3>



<p class="wp-block-paragraph"><code>dig</code> is the only tool that shows you everything: the query it sent, the response status, the TTL remaining, the authority section, and whether the answer came from a cache or from the source. On Debian and Ubuntu it lives in <code>dnsutils</code>; on RHEL-family systems it&#8217;s in <code>bind-utils</code>.</p>



<pre class="wp-block-code"><code># Just the answer, nothing else
dig +short example.com A

# The full picture — status, flags, TTL, authority
dig example.com A

# Ask one specific resolver instead of the system default
dig @1.1.1.1 example.com A

# Ask a specific authoritative server directly
dig @ns1.example-dns.com example.com A</code></pre>



<h3 class="wp-block-heading">host — for quick sanity checks</h3>



<pre class="wp-block-code"><code>host example.com
host -t MX example.com
host -t TXT example.com</code></pre>



<h3 class="wp-block-heading">nslookup — fine, but know its limits</h3>



<p class="wp-block-paragraph"><code>nslookup</code> is everywhere, including Windows, which is its main virtue. But it hides details you often need, and its &#8220;Non-authoritative answer&#8221; banner confuses people constantly — it simply means the answer came from a cache rather than the authoritative server. That&#8217;s completely normal. On Windows I reach for PowerShell instead:</p>



<pre class="wp-block-code"><code>Resolve-DnsName example.com -Type A
Resolve-DnsName example.com -Server 1.1.1.1</code></pre>



<h3 class="wp-block-heading">resolvectl — essential on modern Linux</h3>



<p class="wp-block-paragraph">If the machine runs <code>systemd-resolved</code>, your <code>/etc/resolv.conf</code> probably points at <code>127.0.0.53</code>, which tells you nothing about the real upstream servers. <code>resolvectl</code> does:</p>



<pre class="wp-block-code"><code>resolvectl status
resolvectl query example.com
resolvectl statistics</code></pre>



<h3 class="wp-block-heading">getent — the reality check</h3>



<p class="wp-block-paragraph"><code>dig</code> talks to DNS. Your applications talk to the C library resolver, which also consults <code>/etc/hosts</code> and whatever else <code>/etc/nsswitch.conf</code> lists. When <code>dig</code> and the application disagree, this is the command that explains why:</p>



<pre class="wp-block-code"><code>getent hosts example.com
getent ahosts example.com</code></pre>



<h2 class="wp-block-heading">A Repeatable DNS Troubleshooting Workflow</h2>



<p class="wp-block-paragraph">This is the sequence. Don&#8217;t skip ahead — each step eliminates a whole class of causes, and jumping straight to the authoritative server is how you spend an hour fixing something that was never broken.</p>



<h3 class="wp-block-heading">Step 1 — Confirm it&#8217;s actually DNS</h3>



<p class="wp-block-paragraph">Resolve the name, then connect to the resulting IP directly. If the IP works and the name doesn&#8217;t, it&#8217;s DNS. If neither works, you&#8217;re troubleshooting the wrong layer.</p>



<pre class="wp-block-code"><code>dig +short example.com A
curl -I --resolve example.com:443:203.0.113.10 https://example.com/</code></pre>



<p class="wp-block-paragraph">The <code>--resolve</code> flag is underrated. It forces curl to use an IP you specify while still sending the correct Host header and SNI, which lets you test the web server as if DNS were already fixed. Replace <code>203.0.113.10</code> with the IP you expect.</p>



<h3 class="wp-block-heading">Step 2 — Compare local vs public resolvers</h3>



<pre class="wp-block-code"><code>dig +short example.com A
dig +short @1.1.1.1 example.com A
dig +short @8.8.8.8 example.com A
dig +short @9.9.9.9 example.com A</code></pre>



<p class="wp-block-paragraph">If your local answer differs from all three public resolvers, the problem is on your side — a cache, a hosts file entry, a VPN pushing internal DNS, or a router doing something creative. If all four agree but the answer is wrong, the problem is upstream at the authoritative layer.</p>



<h3 class="wp-block-heading">Step 3 — Find out who is actually authoritative</h3>



<p class="wp-block-paragraph">This is the step people skip, and it&#8217;s the one that catches the nastiest problems. The name servers listed in your DNS provider&#8217;s dashboard are not necessarily the ones the internet is using.</p>



<pre class="wp-block-code"><code># What the parent zone (the registry) says
dig +short NS example.com

# What the zone itself says, straight from one of those servers
dig @ns1.example-dns.com example.com NS</code></pre>



<p class="wp-block-paragraph">Those two lists should match. When they don&#8217;t, you&#8217;ve usually found a half-finished DNS migration: the registrar still delegates to the old provider, so the world keeps reading a zone nobody has updated in months.</p>



<h3 class="wp-block-heading">Step 4 — Query every authoritative server individually</h3>



<p class="wp-block-paragraph">A zone with four name servers can be inconsistent across them if a zone transfer failed. Intermittent, &#8220;it works every third time&#8221; problems are almost always this. Compare the SOA serial on each:</p>



<pre class="wp-block-code"><code># Pull the SOA record from every authoritative server in one shot
dig +nssearch example.com

# Or check them one by one
dig +short @ns1.example-dns.com example.com SOA
dig +short @ns2.example-dns.com example.com SOA</code></pre>



<p class="wp-block-paragraph">Different serial numbers mean the secondaries haven&#8217;t caught up with the primary. Different answers for the same record mean something is genuinely broken in replication.</p>



<h3 class="wp-block-heading">Step 5 — Walk the delegation chain</h3>



<p class="wp-block-paragraph">When nothing else explains it, follow the query the way a recursive resolver would — from the root, down through the TLD, to the authoritative servers:</p>



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



<p class="wp-block-paragraph">Read it top to bottom. Each block is one hop. The point where the output stops making sense — a referral to servers that don&#8217;t answer, a missing glue record, an unexpected NXDOMAIN — is your break point. This single command has saved me more time than any other in DNS troubleshooting.</p>



<h2 class="wp-block-heading">Reading dig Output Without Guessing</h2>



<p class="wp-block-paragraph">The status line in a <code>dig</code> response tells you the category of failure immediately. Learn these four and you&#8217;ve eliminated most of the guesswork.</p>



<ul class="wp-block-list"><li><strong>NOERROR</strong> — the query succeeded. Note that NOERROR with an empty answer section is <em>not</em> a failure: it means the name exists but has no record of the type you asked for. Asking for an A record on a name that only has MX records gives you exactly this.</li><li><strong>NXDOMAIN</strong> — the name does not exist at all. Not a typo in the record; the label itself isn&#8217;t in the zone. Check for a missing record, a misspelled hostname, or a zone that isn&#8217;t loaded.</li><li><strong>SERVFAIL</strong> — the resolver tried and failed. This is the ambiguous one, and in practice it usually means DNSSEC validation failure, an authoritative server that&#8217;s unreachable or refusing queries, or a broken delegation.</li><li><strong>REFUSED</strong> — the server you asked is not willing to answer for that zone. Typically you&#8217;re querying a server that isn&#8217;t authoritative and doesn&#8217;t do recursion for you.</li></ul>



<p class="wp-block-paragraph">Two other things worth reading in every response:</p>



<ul class="wp-block-list"><li><strong>The <code>aa</code> flag</strong> — present when the answer came from an authoritative server. If it&#8217;s missing, you&#8217;re looking at a cached answer, and the TTL tells you how stale it might be.</li><li><strong>The TTL column</strong> — on a cached answer this counts <em>down</em>. Query the same resolver twice a few seconds apart; if the TTL drops, you&#8217;re being served from cache. If it stays constant, you&#8217;re hitting the source.</li></ul>




<h2 class="wp-block-heading">The Propagation Myth (and What&#8217;s Really Happening)</h2>



<p class="wp-block-paragraph">&#8220;DNS propagation takes 24 to 48 hours&#8221; is the most repeated and least accurate sentence in web hosting. Nothing propagates. When you change a record, the authoritative server has the new value instantly. What takes time is the expiry of cached copies held by thousands of independent resolvers — and that duration is entirely determined by the TTL you set <em>before</em> the change.</p>



<p class="wp-block-paragraph">The practical consequence: lowering a TTL after you&#8217;ve made the change does nothing for anyone who already cached the old value. Plan ahead instead.</p>



<ol class="wp-block-list"><li>Well before a migration, drop the TTL on the records you&#8217;ll change to something short — 300 seconds is a common choice.</li><li>Wait at least the length of the <em>old</em> TTL so the short value fully replaces it in caches.</li><li>Make the actual change. Old cached answers now expire within minutes.</li><li>Verify against several public resolvers and from a couple of different networks.</li><li>Once everything is stable, raise the TTL back to something sane — an hour or more — so you&#8217;re not paying for lookups you don&#8217;t need.</li></ol>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">The TTL you set today determines how painful next month&#8217;s migration will be. It&#8217;s the cheapest piece of planning in infrastructure work, and the one most consistently skipped.</p>
</blockquote>



<p class="wp-block-paragraph">One more thing people miss: <strong>negative caching</strong>. When a lookup returns NXDOMAIN, resolvers cache that &#8220;doesn&#8217;t exist&#8221; answer too, for a duration derived from the minimum field in the zone&#8217;s SOA record. So if you query a hostname <em>before</em> creating it, you may keep getting NXDOMAIN for a while even after the record is live. Check what you&#8217;re in for:</p>



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



<p class="wp-block-paragraph">The last number in that output is the negative caching TTL. Resist the urge to test a hostname before you&#8217;ve actually created it.</p>



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



<h2 class="wp-block-heading">Failure Patterns You&#8217;ll Meet Again and Again</h2>



<h3 class="wp-block-heading">A CNAME on the zone apex</h3>



<p class="wp-block-paragraph">You cannot put a plain CNAME on the root of a domain. The apex must carry SOA and NS records, and a CNAME is not allowed to coexist with other records at the same name. Providers work around this with ALIAS, ANAME, or &#8220;CNAME flattening&#8221; records that resolve the target server-side and return an A record. If your root domain misbehaves while <code>www</code> is fine, this is the first thing to check.</p>



<h3 class="wp-block-heading">Stale cache — everywhere at once</h3>



<p class="wp-block-paragraph">Correct at the authoritative server, wrong on your machine. Clear caches from the outside in:</p>



<pre class="wp-block-code"><code># Linux with systemd-resolved
sudo resolvectl flush-caches

# Linux with nscd
sudo systemctl restart nscd

# macOS
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder

# Windows (run as Administrator)
ipconfig /flushdns</code></pre>



<p class="wp-block-paragraph">Then remember the browser. Chromium-based browsers keep a separate cache you can clear at <code>chrome://net-internals/#dns</code>. And if the site uses HSTS, a browser may refuse to fall back to HTTP regardless of what DNS says — test with <code>curl</code> before you conclude anything from a browser.</p>



<h3 class="wp-block-heading">A forgotten line in /etc/hosts</h3>



<p class="wp-block-paragraph">Someone adds a hosts entry during a migration to test the new server, and never removes it. Six months later the server is &#8220;randomly&#8221; hitting the wrong backend. It costs three seconds to rule out:</p>



<pre class="wp-block-code"><code>grep -i example.com /etc/hosts
getent hosts example.com</code></pre>



<h3 class="wp-block-heading">DNSSEC validation failures</h3>



<p class="wp-block-paragraph">A domain with broken DNSSEC returns SERVFAIL from validating resolvers while working perfectly on non-validating ones. That produces the maddening symptom where the site loads for some users and not others, with no geographic pattern.</p>



<p class="wp-block-paragraph">The diagnostic trick is <code>+cd</code>, which disables validation. If the query fails normally but succeeds with <code>+cd</code>, DNSSEC is your culprit:</p>



<pre class="wp-block-code"><code>dig @1.1.1.1 example.com A
dig @1.1.1.1 +cd example.com A

# Show the DNSSEC records themselves
dig example.com DNSKEY +dnssec
dig example.com DS

# delv performs validation and explains the outcome
delv example.com A</code></pre>



<p class="wp-block-paragraph">The usual cause is a DS record at the registrar that no longer matches the keys published in the zone — classic after changing DNS providers without disabling DNSSEC first. <strong>Always turn DNSSEC off at the registrar before migrating name servers, then re-enable it once the new provider is live and stable.</strong></p>



<h3 class="wp-block-heading">Split-horizon DNS surprises</h3>



<p class="wp-block-paragraph">Internal resolvers hand out private addresses; external resolvers hand out public ones. Perfectly intentional — until a VPN, a container, or a misconfigured search domain puts a client on the wrong side of the split. When a name resolves differently depending on <em>where</em> you run the query, compare the local resolver against a public one and check whether a VPN profile is pushing its own DNS servers.</p>



<h3 class="wp-block-heading">Proxied records hiding the origin</h3>



<p class="wp-block-paragraph">If a domain sits behind Cloudflare or a similar proxy, public DNS returns the proxy&#8217;s IP, not your server&#8217;s. That&#8217;s the whole point — but it means you cannot verify an origin change through public DNS at all. Query the authoritative provider&#8217;s own API or dashboard for the real record value, and test the origin directly with <code>curl --resolve</code>.</p>



<h3 class="wp-block-heading">Wildcards masking a missing record</h3>



<p class="wp-block-paragraph">A <code>*</code> record in the zone means every hostname resolves — including the ones you typo&#8217;d, and including subdomains you thought you&#8217;d deleted. If everything resolves but half of it points somewhere unexpected, check for a wildcard before you go hunting for phantom records.</p>



<h3 class="wp-block-heading">Missing trailing dots in zone files</h3>



<p class="wp-block-paragraph">Hand-editing BIND zone files? A record value without a trailing dot gets the origin appended to it. Write <code>mail.example.com</code> instead of <code>mail.example.com.</code> and you&#8217;ve just created <code>mail.example.com.example.com</code>. Validate before reloading:</p>



<pre class="wp-block-code"><code>named-checkconf
named-checkzone example.com /var/named/example.com.zone</code></pre>



<p class="wp-block-paragraph">And bump the SOA serial. A zone edit without a serial increment won&#8217;t reach the secondaries, which is exactly the inconsistency you were chasing in Step 4.</p>



<h3 class="wp-block-heading">Containers and the ndots trap</h3>



<p class="wp-block-paragraph">Inside Kubernetes pods, <code>/etc/resolv.conf</code> commonly sets <code>ndots:5</code>. Any name with fewer than five dots gets tried against every search domain first, so an external lookup like <code>api.example.com</code> can generate several failed queries before the correct one. It works — it&#8217;s just slow, and under load it looks like intermittent DNS failure. Adding a trailing dot to fully qualify the name skips the search list entirely:</p>



<pre class="wp-block-code"><code>cat /etc/resolv.conf
dig api.example.com.        # note the trailing dot</code></pre>



<h2 class="wp-block-heading">DNS Troubleshooting for Email Delivery</h2>



<p class="wp-block-paragraph">Mail problems are DNS problems more often than they are mail-server problems. When messages bounce or land in spam, check these before touching the mail server config:</p>



<pre class="wp-block-code"><code># Where should mail for this domain go?
dig +short example.com MX

# SPF and DMARC live in TXT records
dig +short example.com TXT
dig +short _dmarc.example.com TXT

# DKIM lives under a selector you choose
dig +short selector1._domainkey.example.com TXT

# Reverse DNS for the sending IP
dig -x 203.0.113.10 +short</code></pre>



<p class="wp-block-paragraph">Things that regularly go wrong here:</p>



<ul class="wp-block-list"><li><strong>Two SPF records.</strong> A domain must publish exactly one. Two SPF TXT records is a permanent error, and receiving servers are entitled to fail the check outright. Merge them into one.</li><li><strong>An MX pointing at a CNAME.</strong> MX targets must resolve to address records, not aliases.</li><li><strong>Missing reverse DNS.</strong> If the PTR record for your sending IP doesn&#8217;t match the hostname the server announces, expect a lot of spam folder.</li><li><strong>DKIM selector mismatch.</strong> The selector in the message header must match the one published in DNS — a detail that quietly breaks after a mail platform migration.</li></ul>



<p class="wp-block-paragraph">While you&#8217;re in TXT territory: if certificate issuance is failing, check for a CAA record. A CAA entry that only authorises one certificate authority will silently block every other one, and the error you get back from the ACME client rarely says &#8220;DNS.&#8221;</p>



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



<h2 class="wp-block-heading">Mistakes That Cost the Most Time</h2>



<ul class="wp-block-list"><li><strong>Testing only in a browser.</strong> Browser caches, HSTS, service workers, and QUIC connection reuse will all lie to you. Confirm with <code>dig</code> and <code>curl</code>.</li><li><strong>Trusting the control panel.</strong> The dashboard shows what the zone <em>should</em> contain. Only a query against the authoritative server shows what it actually serves.</li><li><strong>Changing several things at once.</strong> Adjust one record, verify, then move on. Batch changes turn a five-minute fix into an archaeology project.</li><li><strong>Forgetting IPv6.</strong> A stale or wrong AAAA record produces &#8220;works for some people&#8221; behaviour, because dual-stack clients generally prefer IPv6. Always check <code>dig +short example.com AAAA</code>.</li><li><strong>Leaving DNSSEC enabled through a provider migration.</strong> The single most reliable way to take a domain fully offline for hours.</li><li><strong>Not documenting the zone before a migration.</strong> Export or record every record first. Rebuilding a zone from memory at 2am is not a plan.</li></ul>



<h2 class="wp-block-heading">Best Practices That Prevent the Next Incident</h2>



<ul class="wp-block-list"><li><strong>Use at least two name servers</strong>, ideally on separate networks or providers. Single-provider DNS is a single point of failure for your entire presence.</li><li><strong>Keep TTLs deliberate.</strong> Long TTLs for stable infrastructure, short ones ahead of planned changes. Not the other way round.</li><li><strong>Monitor DNS resolution itself</strong>, not just HTTP endpoints. A check that queries your authoritative servers directly and alerts on a changed or missing answer catches problems before users do.</li><li><strong>Version-control your zone data</strong> if your provider supports API or zone-file export. Diffing two zone versions turns &#8220;what changed?&#8221; into a one-second question.</li><li><strong>Keep a record inventory</strong> — what each record is for, who owns it, and whether it&#8217;s still needed. Old A records pointing at decommissioned servers are a genuine security risk, not just clutter.</li><li><strong>Standardise your checks.</strong> A short script that runs your Step 1–5 sequence against a domain makes DNS troubleshooting repeatable instead of improvised.</li></ul>



<h2 class="wp-block-heading">A Quick Reference You Can Keep</h2>



<pre class="wp-block-code"><code># Basic resolution
dig +short example.com A
dig +short example.com AAAA

# Compare resolvers
dig +short @1.1.1.1 example.com A
dig +short @8.8.8.8 example.com A

# Authority and delegation
dig +short example.com NS
dig +trace example.com A
dig +nssearch example.com

# Cache behaviour
dig example.com A            # watch the TTL count down
dig +norecurse @1.1.1.1 example.com A   # cached-only lookup

# DNSSEC
dig +cd example.com A
delv example.com A

# Mail and verification records
dig +short example.com MX
dig +short example.com TXT
dig +short _dmarc.example.com TXT
dig -x 203.0.113.10 +short

# System-level view
resolvectl status
getent hosts example.com
cat /etc/resolv.conf</code></pre>



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



<h2 class="wp-block-heading">Frequently Asked Questions</h2>



<h3 class="wp-block-heading">How long does a DNS change really take to take effect?</h3>



<p class="wp-block-paragraph">At the authoritative server, immediately. For everyone else, up to the TTL that was in place when they last cached the record. If the old TTL was 3600 seconds, worst case is roughly an hour. The &#8220;24 to 48 hours&#8221; figure is a legacy of very long default TTLs and badly behaved resolvers, not a rule.</p>



<h3 class="wp-block-heading">What&#8217;s the difference between SERVFAIL and NXDOMAIN?</h3>



<p class="wp-block-paragraph">NXDOMAIN is a definitive answer: the name doesn&#8217;t exist. SERVFAIL means the resolver couldn&#8217;t produce an answer at all — commonly a DNSSEC validation failure, an unreachable authoritative server, or a broken delegation. NXDOMAIN points you at the zone contents; SERVFAIL points you at the infrastructure.</p>



<h3 class="wp-block-heading">Why does the site work for me but not for a colleague?</h3>



<p class="wp-block-paragraph">Different resolvers with different cache states, a hosts file entry on one machine, a VPN pushing internal DNS, an IPv6 versus IPv4 difference, or DNSSEC failing only on validating resolvers. Have both people run the same <code>dig</code> against the same public resolver — if the answers match, the difference is local to one machine.</p>



<h3 class="wp-block-heading">Should I use dig or nslookup?</h3>



<p class="wp-block-paragraph"><code>dig</code>, whenever it&#8217;s available. It shows response status, flags, TTLs and the authority section, all of which you need for real DNS troubleshooting. Use <code>nslookup</code> or <code>Resolve-DnsName</code> when you&#8217;re on a Windows box without other tooling.</p>



<h3 class="wp-block-heading">Do online &#8220;DNS propagation checker&#8221; sites actually help?</h3>



<p class="wp-block-paragraph">They&#8217;re useful for one thing: seeing what different resolvers around the world currently have cached. They won&#8217;t tell you <em>why</em> an answer is wrong, and they can&#8217;t see proxied origins or internal split-horizon zones. Treat them as a rough sanity check, not a diagnosis.</p>



<h3 class="wp-block-heading">Can a CNAME point to another CNAME?</h3>



<p class="wp-block-paragraph">Technically yes, and resolvers will follow the chain — but every extra hop adds latency and another thing to break. Keep chains short, and never let one loop back on itself. Also remember a CNAME cannot coexist with other records at the same name, which is why it doesn&#8217;t belong on a zone apex.</p>



<h3 class="wp-block-heading">Why do my DNS changes keep reverting?</h3>



<p class="wp-block-paragraph">Usually because something else owns the zone: an infrastructure-as-code pipeline, a control panel that rewrites records on certain actions, or an external DNS controller in a Kubernetes cluster. Decide on one source of truth and make every change through it.</p>



<h3 class="wp-block-heading">Is switching to a public resolver a fix?</h3>



<p class="wp-block-paragraph">It&#8217;s a workaround for one user, not a fix for the domain. It&#8217;s genuinely useful as a diagnostic — if 1.1.1.1 gives the right answer and your ISP&#8217;s resolver doesn&#8217;t, you&#8217;ve isolated the problem to that resolver&#8217;s cache. But if the record itself is wrong, changing resolvers just changes who tells you the wrong thing.</p>



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



<p class="wp-block-paragraph">Good DNS troubleshooting isn&#8217;t about knowing obscure record types. It&#8217;s about discipline: confirm it&#8217;s DNS, compare resolvers, find out who&#8217;s really authoritative, ask each of them directly, and follow the delegation chain when the answers stop agreeing. Five steps, in order, every time.</p>



<p class="wp-block-paragraph">Do that consistently and most incidents collapse into something small and obvious — a stale cache, a TTL nobody lowered, a DS record left behind after a migration, a hosts entry from six months ago. The problems aren&#8217;t usually exotic. They&#8217;re just invisible until you ask the right party the right question.</p>



<p class="wp-block-paragraph">Keep the quick-reference commands somewhere you can reach in thirty seconds, set your TTLs before you need them, and monitor resolution as seriously as you monitor uptime. Future you will appreciate it at 2am.</p>



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



<h2 class="wp-block-heading">Need a Hand With DNS, Hosting or Cloud Infrastructure?</h2>



<p class="wp-block-paragraph">If you&#8217;re stuck on a DNS issue, planning a migration you&#8217;d rather not do twice, or you just want someone to review a zone before you flip the switch, I&#8217;m available for freelance work.</p>



<p class="wp-block-paragraph">I work on DNS configuration and troubleshooting, domain and hosting migrations, SSL and certificate issues, email deliverability records (SPF, DKIM, DMARC), server setup and hardening, AWS and cloud infrastructure, and monitoring and alerting — plus WordPress performance and maintenance.</p>



<p class="wp-block-paragraph">You can see my profile, past work and reviews on Upwork, and message me directly there with what you&#8217;re dealing with.</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">Hire Me on Upwork</a></div>
</div>



<p class="wp-block-paragraph">Got a DNS problem this article didn&#8217;t cover? Leave a comment describing the symptoms and what <code>dig +trace</code> shows — that output alone usually narrows it down fast.</p>

<!-- /wp:post-content --><p>The post <a href="https://john-nessime.com/blog/networking/dns-troubleshooting/">DNS Troubleshooting: How to Find the Real Cause in Minutes, Not Hours</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
