<?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>MX Records | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/mx-records/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/mx-records/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 11:08:01 +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>MX Records | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/mx-records/</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>Post-Migration Checklist: What Breaks Quietly in the First 48 Hours</title>
		<link>https://john-nessime.com/blog/devops/post-migration-checklist-48-hours/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 20 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Certbot]]></category>
		<category><![CDATA[Cron]]></category>
		<category><![CDATA[Cutover Planning]]></category>
		<category><![CDATA[Decommissioning]]></category>
		<category><![CDATA[DKIM]]></category>
		<category><![CDATA[DNS Migration]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[MX Records]]></category>
		<category><![CDATA[Restore Testing]]></category>
		<category><![CDATA[Rollback Plan]]></category>
		<category><![CDATA[Synthetic Monitoring]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[TTL]]></category>
		<category><![CDATA[Uptime Monitoring]]></category>
		<category><![CDATA[Website Migration]]></category>
		<category><![CDATA[WP-Cron]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=278</guid>

					<description><![CDATA[<p>Migrations rarely fail loudly. The site stays up while mail, cron, certificate renewal and the write path break silently behind it. A post-migration checklist for the first 48 hours, ordered by how long each failure can hide rather than by subsystem.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/post-migration-checklist-48-hours/">Post-Migration Checklist: What Breaks Quietly in the First 48 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">The ticket you don&#8217;t want after a migration doesn&#8217;t say the site is down. It says nobody has received a contact form email since Friday.</p>



<p class="wp-block-paragraph">The site never went down. The uptime monitor stayed green all weekend. Analytics looked normal. And a few days of enquiries went somewhere nobody thought to look.</p>



<p class="wp-block-paragraph">That&#8217;s the shape of most migration damage. Not the 502. A 502 gets fixed inside ten minutes because everyone sees it at once, including the client. The expensive failures are the ones with no symptom on the front end: mail that authenticates on the old IP and not the new one, a cron job that never came across, a certificate that is valid today and will fail to renew long after everyone has stopped paying attention, orders written to a database that is about to be deleted.</p>



<p class="wp-block-paragraph">This post-migration checklist covers the first 48 hours after cutover. It&#8217;s ordered by when each failure actually becomes visible, not by category, because that ordering is what decides which problems you catch yourself and which ones reach you as a client ticket a fortnight later.</p>



<h2 class="wp-block-heading">Why a post-migration checklist should be ordered by detection latency</h2>



<p class="wp-block-paragraph">Most migration checklists you&#8217;ll find are grouped by subsystem: DNS, SSL, email, SEO, performance. That&#8217;s a fine way to write a document and a bad way to work a cutover, because it treats a 500 error on the homepage and a broken certificate renewal as equally urgent. They aren&#8217;t. One will be reported to you within minutes by someone who isn&#8217;t you. The other has a detection latency measured in weeks, and it lands at 3am.</p>



<p class="wp-block-paragraph">So sort your checks by how long the failure can hide. Short latency, catch it reactively. Long latency, go and look for it on purpose, while you still have the old server, the old data and a rollback path. That&#8217;s what makes 48 hours the right frame: long enough for a full daily cron cycle and for DNS to settle at any sane TTL, short enough that the old server is still sitting there with the evidence on it.</p>



<h2 class="wp-block-heading">Hour 0 to 1: prove you are actually looking at the new server</h2>



<p class="wp-block-paragraph">Almost everyone starts by loading the site in a browser and saying it looks fine. That check is worth close to nothing an hour after cutover. Your laptop may still hold the old answer, your ISP resolver definitely might, and if you added a hosts entry during testing you&#8217;re now testing the thing you already tested. Ask several resolvers directly, then bypass DNS entirely and talk to the new IP.</p>



<pre class="wp-block-code"><code># What is each public resolver answering, and how long will it keep answering it?
dig example.com A @1.1.1.1
dig example.com A @8.8.8.8
dig example.com A @9.9.9.9

# Talk to the new server directly, ignoring DNS completely
curl -sI --resolve example.com:443:203.0.113.10 https://example.com/

# Which certificate is that IP presenting for that hostname?
openssl s_client -connect 203.0.113.10:443 -servername example.com &lt;/dev/null 2&gt;/dev/null 
  | openssl x509 -noout -subject -issuer -dates</code></pre>



<p class="wp-block-paragraph">The full <code>dig</code> answer section, not <code>+short</code>, matters here because it shows the remaining TTL. That number tells you how much longer stale answers can survive, which is the single most useful figure you have in the first hour.</p>



<p class="wp-block-paragraph">The <code>--resolve</code> flag pins a hostname to an address for that one request, so you get the new server&#8217;s response with the correct SNI and Host header. It&#8217;s the closest thing to a definitive answer you&#8217;ll get while records are still in flight. If you want to see what the rest of the world sees, an exit in another country helps, and a VPN account with NordVPN or Surfshark is a cheaper way to get one than spinning up a box in a second region.</p>



<p class="wp-block-paragraph">Things to confirm in this window:</p>



<ul class="wp-block-list">
<li>The response comes from the new server, verified by something unique to it rather than by the page looking right. A temporary header or a file at a path that only exists on the new box removes all doubt. <em>Pass test: you can name the specific artefact that proves it.</em></li>

<li>The correct virtual host is selected, not a catch-all default. Sites with several domains on one server fail here quietly, serving the wrong document root over a valid certificate. <em>Pass test: every hostname you migrated returns its own content, checked individually.</em></li>

<li>The certificate chain is complete on the new box, not just the leaf. Browsers often paper over a missing intermediate using cached data; API clients and payment callbacks do not. <em>Pass test: an external SSL checker reports a complete chain from a machine that has never visited the site.</em></li>

<li>Redirect behaviour matches the old server exactly. HTTP to HTTPS, www to apex or the reverse, trailing slashes. <em>Pass test: a handful of known deep URLs return the same final status and location as before.</em></li>

<li>Any hosts file entries you added for testing are gone. Leave one in place and you&#8217;ll misread every check you run for the next two days.</li>
</ul>



<h2 class="wp-block-heading">Hour 1 to 6: the split-brain window, where you lose data instead of uptime</h2>



<p class="wp-block-paragraph">This is the part that costs real money, and it&#8217;s the part almost nobody checks, because during it both servers are healthy. Some resolvers still hand out the old address. Those visitors reach the old application, which happily accepts their form submission, their order, their uploaded file, their account signup. Then you delete that server.</p>



<p class="wp-block-paragraph">&#8220;Keep the old server running for 48 hours&#8221; is standard advice and it&#8217;s necessary, but on its own it makes the problem worse rather than better. A running old server that still accepts writes is a data-loss machine. What you want is an old server that stays reachable and stops accepting writes.</p>



<p class="wp-block-paragraph">Three options, in order of how much I&#8217;d trust them:</p>



<ol class="wp-block-list">
<li>Proxy the old server to the new one. Every request that lands on the old address gets forwarded to the new backend, so there is only ever one database. This is the only option that genuinely eliminates the split, and it&#8217;s worth the extra config on anything transactional.</li>

<li>Redirect the old server to a hostname that only resolves to the new IP. Cheap, works for GET traffic, does nothing useful for a POST that&#8217;s already in flight.</li>

<li>Put the old application into a read-only or maintenance state. Safe, but visitors on stale DNS see a broken site for the length of your TTL, so this only works if that number is genuinely small.</li>
</ol>



<p class="wp-block-paragraph">Whichever you pick, go and look at what the old box actually did after the cutover.</p>



<pre class="wp-block-code"><code># On the OLD server: has anything been written since cutover?
grep '"POST ' /var/log/nginx/access.log | tail -n 50

# Apache layouts vary; on DirectAdmin the per-domain log lives here
grep '"POST ' /var/log/httpd/domains/example.com.log | tail -n 50

# Is the old box still receiving mail for the domain?
tail -n 200 /var/log/maillog</code></pre>



<p class="wp-block-paragraph">Every POST in that log after the cutover timestamp is a potential lost record. Take the list to the application: compare row counts or highest IDs on the write-heavy tables between old and new, and reconcile before you decommission anything.</p>



<p class="wp-block-paragraph">Mail deserves its own pass. If MX records changed, messages go to whichever server the sending system had cached, and land in two different mailboxes. Keep both able to accept mail through the overlap, and check the old mailbox before you close the account, not after.</p>



<p class="wp-block-paragraph">Two more things here that won&#8217;t show up in any log you&#8217;re watching:</p>



<ul class="wp-block-list">
<li><strong>Anything pinned to an IP address.</strong> Payment gateway allowlists, webhook endpoints, database firewall rules, SMTP relay authorisations, third-party API allowlists. None of these follow a redirect. A webhook posted to the old IP returns a perfectly good 200 and the new application never hears about the payment.</li>

<li><strong>Outbound connections from the new IP.</strong> Your application is now calling other people&#8217;s APIs from an address they&#8217;ve never seen. If any of those partners allowlist by IP, the failure appears as a timeout in a background job rather than an error on a page.</li>
</ul>



<h2 class="wp-block-heading">Hour 6 to 24: everything that runs on a schedule</h2>



<p class="wp-block-paragraph">Files and databases get migrated. Scheduled work frequently does not, because it lives outside the document root and outside the database dump, and because nothing on the site breaks when it&#8217;s missing.</p>



<pre class="wp-block-code"><code># Every user's crontab, not just root's
for u in $(cut -d: -f1 /etc/passwd); do echo "== $u"; crontab -l -u "$u" 2&gt;/dev/null; done

# systemd timers, where a lot of "cron" actually lives now
systemctl list-timers --all

# WordPress: what is scheduled, and is anything overdue?
wp cron event list --fields=hook,next_run_relative,recurrence
wp config get DISABLE_WP_CRON</code></pre>



<p class="wp-block-paragraph">Run that on both servers and diff the output. That comparison is the whole check, and it takes two minutes.</p>



<p class="wp-block-paragraph">The specific traps worth knowing:</p>



<ul class="wp-block-list">
<li><strong>The old server&#8217;s cron is still running.</strong> Nobody stops it, because &#8220;keep it up for 48 hours&#8221; sounded like keep everything up. Now your nightly export runs twice, your reminder emails send twice, and your billing job runs against a database nobody is reading. Disable cron on the old box at cutover, before you disable anything else.</li>

<li><strong>WP-Cron was disabled in config and the replacement never came across.</strong> If <code>DISABLE_WP_CRON</code> is true and no system cron hits <code>wp-cron.php</code>, every scheduled task stops silently. Scheduled posts sit at &#8220;Missed schedule&#8221;, backups stop, WooCommerce action queues back up. The site itself works perfectly.</li>

<li><strong>The cron URL is wrong in a way that still returns success.</strong> A job calling an <code>http://</code> URL that now redirects to HTTPS gets a 3xx, exits zero, and looks healthy in the log while the task never executes. Same story for a cron still pointing at the old hostname or an old absolute path.</li>

<li><strong>Backups.</strong> The job may have moved and still be writing to storage credentials, a mount, or a destination path that only existed on the old host. Don&#8217;t check that the backup job ran. Check that a file arrived at the destination, and that it&#8217;s the size you expect.</li>

<li><strong>Log rotation.</strong> A fresh server often has no rotation configured for application logs. Nothing goes wrong for three days, then the disk fills at 2am and every write fails at once.</li>
</ul>



<h2 class="wp-block-heading">Hour 24 to 48: the delayed detonations</h2>



<p class="wp-block-paragraph">These are the checks with the longest detection latency, which makes them the ones most worth doing deliberately.</p>



<h3 class="wp-block-heading">Certificate renewal, not the certificate</h3>



<p class="wp-block-paragraph">A copied certificate is valid for weeks. What breaks in a migration is the machinery that replaces it: the webroot path in the renewal config no longer exists, the ACME account keys weren&#8217;t copied, port 80 is closed on the new firewall, or a WAF is now intercepting the challenge path. None of that surfaces until the renewal window opens, long after everyone has moved on.</p>



<pre class="wp-block-code"><code># What does certbot think it manages, and how does it validate?
certbot certificates

# The only check that matters: will the NEXT renewal succeed?
certbot renew --dry-run

# Is anything actually going to run it?
systemctl list-timers | grep -i certbot</code></pre>



<p class="wp-block-paragraph">The dry run validates against Let&#8217;s Encrypt&#8217;s staging environment, so it exercises the real challenge path without consuming your issuance quota. Resist the urge to fix a failure with a forced renewal: Let&#8217;s Encrypt applies rate limits per registered domain and a separate limit on identical certificates, and burning through them during a migration is how a fixable config problem turns into a week of waiting.</p>



<h3 class="wp-block-heading">Mail identity on an address with no history</h3>



<p class="wp-block-paragraph">If the new server sends mail, it&#8217;s sending from an IP that receiving systems have never seen. Authentication correctness and reputation are two separate problems and they fail differently. Authentication failures bounce or land in spam immediately. Reputation problems look like intermittent delivery to one provider and normal delivery to another.</p>



<pre class="wp-block-code"><code># Reverse DNS for the new sending IP
dig +short -x 203.0.113.10

# Does SPF still authorise everything that sends for you?
dig +short TXT example.com

# DKIM selector (replace 'selector') and DMARC policy
dig +short TXT selector._domainkey.example.com
dig +short TXT _dmarc.example.com</code></pre>



<p class="wp-block-paragraph">Send a test message to an address at a major provider and read the full headers. The Authentication-Results header tells you whether SPF, DKIM and DMARC each passed, and crucially whether the passing domain aligns with the visible From address. SPF can pass for the envelope sender and still fail DMARC when the From domain differs, which is a failure mode you cannot see from the sending side.</p>



<p class="wp-block-paragraph">Leave the old server&#8217;s SPF authorisation and DKIM public key published through the overlap. Mail that was queued or delayed on the old path still needs to verify. Sources differ on how long to keep them, and the honest answer is: until you have confirmed nothing is still sending or retrying from the old host.</p>



<p class="wp-block-paragraph">If transactional mail matters to the business, the migration is a reasonable moment to stop sending it from a web server at all. Handing it to a dedicated provider such as Postmark, Mailgun or Amazon SES moves reputation off an address you&#8217;ve just changed and gives you delivery logs you can actually query.</p>



<h3 class="wp-block-heading">Search crawl, redirects and staging leftovers</h3>



<p class="wp-block-paragraph">Crawlers hit harder than usual after a move, because they&#8217;re re-checking old URLs and discovering new ones at the same time. That surge is also your best early warning: it surfaces broken redirects faster than your own testing will.</p>



<ul class="wp-block-list">
<li>Fetch <code>robots.txt</code> from the live site and read it. A staging <code>Disallow: /</code> that survives cutover is the single most damaging thing on this list.</li>

<li>View source on several page types and check for a leftover noindex tag and for canonical tags pointing at a staging hostname.</li>

<li>Watch 404s in the access log rather than in a crawler report. It&#8217;s your missed-redirects list, sorted by how much anyone cares.</li>

<li>Leave the old URLs crawlable through the redirect. Blocking them means the redirects are never seen.</li>
</ul>



<h3 class="wp-block-heading">Resource behaviour under real traffic</h3>



<p class="wp-block-paragraph">Staging never told you what the process pool does at peak. Twenty-four hours of production traffic does.</p>



<pre class="wp-block-code"><code># Status code distribution since cutover
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head

# Disk, and the one people forget
df -h
df -i</code></pre>



<p class="wp-block-paragraph">Compare the ratio of 5xx to total requests against the old server&#8217;s logs for the same weekday. Absolute numbers mislead here; the ratio doesn&#8217;t. Watch memory across a full daily cycle rather than at a single moment, because the interesting shape is a slow climb into a limit, not a spike.</p>



<h2 class="wp-block-heading">Before you decommission the old server</h2>



<p class="wp-block-paragraph">The old server is the only copy of some things, and none of them are in your backup.</p>



<ul class="wp-block-list">
<li>Access and error logs covering the cutover window, which is your evidence for anything that surfaces next month.</li>

<li>Mail spools, and any messages that arrived after the MX change.</li>

<li>The full crontab set, exported to a file rather than a screenshot.</li>

<li>Web server, PHP and database configuration, so you can diff a behavioural difference you notice later.</li>

<li>A final database dump taken after write traffic stopped.</li>
</ul>



<p class="wp-block-paragraph">Keep the TTL low until you&#8217;ve decided you&#8217;re staying. Raising it back to normal is the last step of the migration, not the first thing you do once the site loads. Another month on an InterServer or Contabo VPS is cheap next to reconstructing a week of orders, and the overlap is the only period in which rolling back is still a five-minute operation.</p>



<h2 class="wp-block-heading">Troubleshooting: three symptoms and what they usually mean</h2>



<p class="wp-block-paragraph"><strong>&#8220;It works for me but not for them.&#8221;</strong> Almost always a resolution split. Get the affected user to report what their resolver returns, then check whether the answer is stale DNS, a corporate resolver ignoring your TTL, or a CDN edge still holding the old origin. If you use Cloudflare or a similar proxy in front, remember the origin change and the DNS change are two separate things and only one of them propagates.</p>



<p class="wp-block-paragraph"><strong>&#8220;The site is fine but our mail goes to spam now.&#8221;</strong> Work in this order: reverse DNS for the sending IP, then SPF including the new address, then DKIM signing on the new host, then DMARC alignment with the visible From domain. Only after all four pass should you start thinking about reputation and volume, and reputation on a new IP is a matter of ramping gradually rather than something you can configure.</p>



<p class="wp-block-paragraph"><strong>&#8220;Random 500s that never happened in staging.&#8221;</strong> Look at limits before you look at code. Process pool size, memory limits, database connection caps, open file limits, and file ownership on directories the application writes to. Permission problems after an rsync or archive restore are common and produce errors that look nothing like permission problems from the front end.</p>



<h2 class="wp-block-heading">Common mistakes in the first 48 hours</h2>



<ul class="wp-block-list">
<li>Treating a green uptime check as verification. It confirms one URL returns 200. It says nothing about mail, cron, writes or checkout.</li>

<li>Leaving the old server fully operational, cron and all, instead of leaving it reachable but inert.</li>

<li>Testing while a hosts file entry is still in place.</li>

<li>Raising the TTL back to normal on day one, which throws away your fast rollback.</li>

<li>Checking that the certificate is valid instead of checking that renewal works.</li>

<li>Silencing monitoring for the maintenance window and forgetting to unsilence it.</li>

<li>Declaring the migration done before a full daily cron cycle has completed on the new server.</li>
</ul>



<h2 class="wp-block-heading">If you only do five things</h2>



<ol class="wp-block-list">
<li>Stop writes and cron on the old server, but keep it reachable.</li>

<li>Run <code>certbot renew --dry-run</code> and confirm a timer or cron will actually invoke it.</li>

<li>Send a real test email to a major provider and read the Authentication-Results header.</li>

<li>Diff the scheduled jobs on both servers, then confirm one real backup file landed at its destination.</li>

<li>Grep the old server&#8217;s access log for POST requests after the cutover, and reconcile anything you find.</li>
</ol>



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



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



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



<p class="wp-block-paragraph">Long enough that no resolver anywhere is still handing out the old address, plus enough margin to reconcile data and pull logs. For most sites that&#8217;s a few days. The cost of the extra billing period is trivial compared with losing your rollback path, but keep it inert: reachable, not writable, with cron disabled.</p>



<h3 class="wp-block-heading">Why is my site fine but email broken after a server migration?</h3>



<p class="wp-block-paragraph">Because web and mail are validated by different systems using different records. The web path only needs an A record and a certificate. Mail needs the MX record, reverse DNS on the sending IP, SPF listing that IP, DKIM signing configured on the new host, and DMARC alignment with your From domain. A migration can satisfy the first set completely and break every item in the second.</p>



<h3 class="wp-block-heading">What should I check first if something looks wrong right after cutover?</h3>



<p class="wp-block-paragraph">Establish which server you are talking to before you diagnose anything else. Use <code>curl --resolve</code> to pin the hostname to the new IP and compare that response with what you get through normal DNS. Half of post-cutover panic is people debugging the old server.</p>



<h3 class="wp-block-heading">Can I lose form submissions or orders during DNS propagation?</h3>



<p class="wp-block-paragraph">Yes, and it&#8217;s the most common serious loss in a migration. Visitors on cached DNS reach the old application, which writes to the old database. Nothing errors. Proxying the old server to the new backend removes the problem entirely; short of that, freeze writes during the overlap and check the old access log for POST requests before decommissioning.</p>



<h3 class="wp-block-heading">Why did my SSL certificate stop renewing weeks after the migration?</h3>



<p class="wp-block-paragraph">The certificate itself migrated fine; the renewal configuration didn&#8217;t. Common causes are a webroot path that no longer exists, an ACME account directory that wasn&#8217;t copied, port 80 blocked by the new firewall, a proxy or WAF intercepting the challenge path, or no timer scheduled to run the renewal at all. A dry run within 48 hours catches all of these.</p>



<h3 class="wp-block-heading">Do I need to tell search engines about a server migration?</h3>



<p class="wp-block-paragraph">If the URLs are unchanged and only the hosting moved, no announcement is required. Your job is to make sure crawlers can reach the new server, that nothing carries a staging noindex or a blocking robots rule, and that canonical tags point at live URLs. If the domain or URL structure changed, that&#8217;s a different migration with its own redirect and site-move process.</p>



<h3 class="wp-block-heading">What monitoring should I add specifically for the 48-hour window?</h3>



<p class="wp-block-paragraph">Add checks for the things a homepage ping cannot see: a synthetic transaction through a real form or checkout, a heartbeat from each scheduled job, certificate expiry, and a content check that asserts the right text is present rather than just a 200 status. Most uptime platforms handle the first and last; heartbeat monitoring for cron is the one people skip and the one that catches the silent failures.</p>



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



<p class="wp-block-paragraph">A migration doesn&#8217;t fail when the site goes down. It fails when the site stays up and something behind it stops, quietly, in a way nobody is looking for. Every item on this post-migration checklist exists because the failure it catches produces no visible symptom on the front end.</p>



<p class="wp-block-paragraph">So work the 48 hours in order of detection latency. Prove which server you&#8217;re on, close the write path on the old one, diff the scheduled jobs, dry-run the certificate renewal, and read the headers on a real test email. Then, and only then, raise the TTL and let the old box go.</p>



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



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



<p class="wp-block-paragraph">Migrations are one of the few pieces of infrastructure work where the review matters more than the execution. Things I help with:</p>



<ul class="wp-block-list">
<li>Planning the cutover so the write path is closed on the old server rather than left open, including proxy-forward setups for transactional sites.</li>

<li>Running the 48-hour verification pass on a migration someone else performed, and telling you plainly what&#8217;s still broken.</li>

<li>Repairing certificate renewal, ACME challenge paths and reload hooks that stopped working after the move.</li>

<li>Fixing mail on a new IP: reverse DNS, SPF, DKIM signing and DMARC alignment, or moving transactional mail off the web server entirely.</li>

<li>Rebuilding scheduled jobs, backup destinations and log rotation on the new host, with heartbeat monitoring so silence becomes an alert.</li>

<li>Reconciling data written to the old server during the split window, before anything gets deleted.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;re mid-migration or just came out of one, send me something concrete: the old server&#8217;s access log around the cutover, your crontab, or the headers from a test email. That&#8217;s usually enough to tell you what&#8217;s wrong.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/post-migration-checklist-48-hours/">Post-Migration Checklist: What Breaks Quietly in the First 48 Hours</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>
