<?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>Networking | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/networking/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/networking/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Thu, 29 Oct 2026 06:05:05 +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>Networking | John Nessime</title>
	<link>https://john-nessime.com/blog/networking/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Salesforce AWS PrivateLink: The Failures Nobody Warns You About</title>
		<link>https://john-nessime.com/blog/technical-guides/salesforce-aws-privatelink/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 29 Oct 2026 06:05:05 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Networking]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Hyperforce]]></category>
		<category><![CDATA[Interface Endpoint]]></category>
		<category><![CDATA[Named Credentials]]></category>
		<category><![CDATA[Network Isolation]]></category>
		<category><![CDATA[Private Hosted Zone]]></category>
		<category><![CDATA[PrivateLink]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[Route 53]]></category>
		<category><![CDATA[Salesforce]]></category>
		<category><![CDATA[Salesforce Private Connect]]></category>
		<category><![CDATA[SNI]]></category>
		<category><![CDATA[TLS]]></category>
		<category><![CDATA[Transit Gateway]]></category>
		<category><![CDATA[VPC]]></category>
		<category><![CDATA[VPC Flow Logs]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=330</guid>

					<description><![CDATA[<p>Salesforce Private Connect over AWS PrivateLink does not fail loudly. It fails by working: green statuses, successful callouts, and packets still crossing the public internet because one DNS record was wrong. Here are the six failure families that actually bite, and how to prove your traffic is private.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/salesforce-aws-privatelink/">Salesforce AWS PrivateLink: The Failures Nobody Warns You About</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket said the integration was &#8220;already private.&#8221; Someone had bought the license, clicked through the setup wizard, seen a green status in the org, and closed the epic. Six weeks later a security review pulled VPC flow logs and asked a simple question: why is this traffic still leaving through the NAT gateway?</p>



<p class="wp-block-paragraph">That is the failure mode worth understanding before you touch anything. A Salesforce AWS PrivateLink setup does not fail loudly. It fails by working. The callout succeeds, the payload arrives, the dashboard is green, and the packets went over the public internet anyway because one DNS record was wrong. Nothing in either console tells you.</p>



<p class="wp-block-paragraph">This post covers what actually breaks when you wire Salesforce to AWS over private connectivity: the DNS trap above, the throughput ceiling nobody budgets for, the TLS and port constraints, why your sandbox cannot rehearse the change, and how to prove after the fact that traffic really is staying on the AWS network.</p>



<h2 class="wp-block-heading">What Private Connect actually gives you</h2>



<p class="wp-block-paragraph">Salesforce Private Connect is the product name. AWS PrivateLink is the underlying mechanism. Salesforce runs a managed transit VPC in each supported AWS region, and you attach to it: either your VPC creates an interface endpoint pointing at their endpoint service, or their transit VPC creates an endpoint pointing at yours.</p>



<p class="wp-block-paragraph">Two things people consistently get wrong here.</p>



<p class="wp-block-paragraph">First, the direction naming is from the org&#8217;s point of view, not yours. <strong>Inbound</strong> means traffic entering Salesforce, so your AWS workload calling the Salesforce REST or Bulk API. <strong>Outbound</strong> means Salesforce calling out to something you host. If you are an AWS engineer, &#8220;inbound&#8221; is the one that feels like outbound to you. Half the confused meetings I have seen on this trace back to that single word.</p>



<p class="wp-block-paragraph">Second, Private Connect only builds the pipe. It does not move data. You still need Apex callouts, External Services, Platform Events, MuleSoft, Amazon AppFlow, or whatever else was already doing the work. If your integration is broken today, a private path will carry the broken requests privately.</p>



<blockquote class="is-layout-flow wp-block-quote-is-layout-flow"><p>Private connectivity is a network property, not an application feature. Nothing above layer 4 changes. Plan the rollout as a routing change, not an integration project.</p></blockquote>



<h2 class="wp-block-heading">Failure family one: DNS that quietly resolves to the public internet</h2>



<p class="wp-block-paragraph">This is the one that produced the ticket at the top, and it is by far the most common way a Salesforce AWS PrivateLink deployment ends up decorative.</p>



<p class="wp-block-paragraph">For the inbound case, your application calls a hostname like <code>yourorg.my.salesforce.com</code>. That hostname resolves publicly. Creating the interface endpoint does not change that. Unless something inside your VPC overrides the answer, the resolver returns Salesforce&#8217;s public addresses and your traffic takes the normal internet path. The endpoint sits there costing you money and carrying nothing.</p>



<p class="wp-block-paragraph">The override is a Route 53 private hosted zone, associated with the VPC that holds the endpoint, containing a record that points the org hostname at the endpoint. Salesforce&#8217;s own walkthrough names the zone <code>my.salesforce.com</code> and puts the org-specific record inside it.</p>



<p class="wp-block-paragraph">Three things have to line up or the override silently does nothing:</p>



<ul class="wp-block-list"><li><strong>The zone is associated with the right VPC.</strong> A private hosted zone answers only for VPCs it is attached to. Query it from anywhere else and the request falls through to public recursion.</li><li><strong>The VPC has DNS support and DNS hostnames enabled.</strong> Both attributes must be on, or the VPC resolver will not serve the zone at all.</li><li><strong>Callers actually use the VPC resolver.</strong> Workloads pinned to an external resolver, containers with a hardcoded <code>nameserver</code> line, or on-premises clients arriving over Direct Connect will bypass the zone entirely unless you front it with a Route 53 Resolver inbound endpoint.</li></ul>



<p class="wp-block-paragraph">There is a sharp edge in the other direction too. If a private hosted zone matches the queried name but contains no record of the right type, Route 53 returns NXDOMAIN rather than falling through to public DNS. So a half-finished zone does not degrade gracefully, it takes the hostname down. Creating the zone and the record in separate change windows is how teams turn a routing improvement into an outage.</p>



<p class="wp-block-paragraph">Verify from inside the VPC, not from your laptop:</p>



<pre class="wp-block-code"><code># Run this on an instance in the VPC associated with the private zone.
dig +short yourorg.my.salesforce.com

# You want RFC 1918 addresses from your endpoint subnets.
# Public addresses mean the private zone is not being consulted.

# Confirm which resolver answered:
dig yourorg.my.salesforce.com | grep -i "SERVER:"</code></pre>



<h2 class="wp-block-heading">Failure family two: the throughput ceiling nobody reads</h2>



<p class="wp-block-paragraph">Private Connect is rate limited, and the default entitlement is small enough to catch people out badly.</p>



<p class="wp-block-paragraph">Salesforce documents an initial license entitlement of 225 MB of data per hour, measured separately for inbound and outbound, with an add-on ceiling far higher. The allowance does not roll over. It resets on the hour.</p>



<p class="wp-block-paragraph">Now consider what a nightly Bulk API extract looks like. A few gigabytes pushed in a twenty minute window is entirely normal and completely incompatible with a 225 MB hourly bucket. The two directions behave differently when you hit the wall, which matters more than the number itself:</p>



<ul class="wp-block-list"><li><strong>Outbound is enforced technically.</strong> Exceed it and outbound traffic stops until the counter resets at the top of the next hour. Your Apex callouts start failing. This is a production incident.</li><li><strong>Inbound is enforced contractually.</strong> Salesforce documents that you are responsible for monitoring and enforcing the inbound limit yourself. Nothing throttles you at the network layer, but you are outside your agreement, and standard enterprise API limits still apply on top.</li></ul>



<p class="wp-block-paragraph">The outbound allowance is visible through the Limits API as <code>PrivateConnectOutboundCalloutHourlyLimitMB</code>, which is what makes it monitorable. Poll it, put it on a dashboard next to your other API limit metrics in Grafana or CloudWatch, and alert well before exhaustion rather than after the callouts fail.</p>



<p class="wp-block-paragraph">If your volume genuinely exceeds the entitlement, the honest options are buying more, reshaping the workload to spread across hours, or accepting that bulk movement stays on a different path and Private Connect covers only your latency-sensitive transactional traffic. That last one is a legitimate architecture, not a failure.</p>



<h2 class="wp-block-heading">Failure family three: TLS, SNI, and the port you cannot use</h2>



<p class="wp-block-paragraph">Private Connect inbound supports port 443 only. Port 8443 is explicitly not supported because Salesforce uses it for mutual TLS. If your integration depends on client certificate authentication on that port, this design does not cover it, and finding out during cutover is expensive.</p>



<p class="wp-block-paragraph">The subtler problem is SNI. Salesforce documents that Hyperforce requires callers to include the Server Name Indication extension in the TLS ClientHello so the right certificate can be served, and that clients calling a hostname outside the <code>*.my.salesforce.com</code> or <code>*.sandbox.my.salesforce.com</code> patterns will hit HTTPS errors. Browsers always send SNI. Older HTTP libraries, embedded clients, and some middleware stacks do not.</p>



<p class="wp-block-paragraph">The reason this bites specifically during a PrivateLink migration is that you are changing what the name resolves to while keeping the name identical. A client that was quietly relying on the public path&#8217;s behavior now hits a different frontend. Test the handshake explicitly:</p>



<pre class="wp-block-code"><code># From inside the VPC, forcing SNI to the org hostname.
openssl s_client -connect yourorg.my.salesforce.com:443 
  -servername yourorg.my.salesforce.com &lt;/dev/null

# Compare with SNI omitted. If this one fails and the above
# succeeds, your client library is the problem, not the network.
openssl s_client -connect yourorg.my.salesforce.com:443 &lt;/dev/null</code></pre>



<p class="wp-block-paragraph">On the outbound side the mirror image applies. Salesforce is the client calling your endpoint service, and it will validate your certificate against the hostname it was told to use. If your Network Load Balancer terminates TLS with a certificate whose subject alternative names do not include the private DNS name you registered, the callout fails on hostname verification. Issue the certificate for a name you control and that matches what the named credential targets.</p>



<h2 class="wp-block-heading">Failure family four: you cannot rehearse this properly</h2>



<p class="wp-block-paragraph">This one deserves its own section because it changes how you plan the work.</p>



<p class="wp-block-paragraph">Private connections are not copied from production into sandboxes. You recreate them. And the ability to recreate them depends on sandbox type:</p>



<ul class="wp-block-list"><li><strong>Full and Partial Copy sandboxes:</strong> you can create connections and provision them. This is where genuine rehearsal happens.</li><li><strong>Developer and Developer Pro sandboxes:</strong> you can create connections but cannot provision them.</li><li><strong>Scratch orgs and Developer orgs:</strong> create only, no provisioning.</li></ul>



<p class="wp-block-paragraph">If your organization only has Developer sandboxes, there is no lower environment where the private path can be exercised end to end. That is a procurement conversation, not an engineering one, and it needs to happen at design time. Budget for a Full or Partial Copy sandbox or accept that production is your first real test.</p>



<p class="wp-block-paragraph">Related trap: connections are packageable while unprovisioned, and unprovisioned connections do not consume a license. That is useful for shipping configuration through a deployment pipeline, but it also means a successful deploy proves nothing about connectivity.</p>



<h2 class="wp-block-heading">Failure family five: license arithmetic and connection identity</h2>



<p class="wp-block-paragraph">Each Private Connect license covers one provisioned connection in each direction. Each connection is a one-to-one mapping between an org ID and a VPC endpoint ID.</p>



<p class="wp-block-paragraph">Read that mapping carefully, because it is where multi-account AWS estates get expensive. Separate VPCs mean separate endpoints mean separate connections mean separate licenses. Separate orgs mean the same again. A design with three application VPCs across two orgs is not one connection, it is six.</p>



<p class="wp-block-paragraph">The architectural answer is usually a shared services or egress VPC that owns the endpoint, with the application VPCs reaching it through a Transit Gateway. You pay for one endpoint and route to it. This is the same centralized-endpoint pattern you would already use for interface endpoints to AWS services, and the reasoning is identical: endpoints are billed per attachment, so consolidate them.</p>



<p class="wp-block-paragraph">Four inbound connections consume four licenses. If you hold four licenses, you have used all of them and have none left for outbound. Count both directions before the renewal conversation.</p>



<h2 class="wp-block-heading">Failure family six: the AWS side of an outbound connection</h2>



<p class="wp-block-paragraph">For outbound, you are the service provider. Salesforce&#8217;s transit VPC creates the endpoint into your account, which means you have to build a proper endpoint service.</p>



<p class="wp-block-paragraph">The sequence, in the order that avoids rework:</p>



<ol class="wp-block-list"><li>Stand up a Network Load Balancer in front of your service, across at least two Availability Zones.</li><li>Create the VPC endpoint service backed by that NLB.</li><li>Copy the IAM role ARN from the Private Connect setup page in Salesforce for your region, and add it to the allowed principals on the endpoint service. Without this, Salesforce cannot connect and the failure looks like a generic timeout.</li><li>Register a private DNS name on the endpoint service and complete domain ownership verification.</li><li>Create the outbound connection in Salesforce using the endpoint service name, then sync it to pick up the AWS-side status.</li><li>Associate the connection with a named credential. Callouts only take the private path when they go through a named credential bound to that connection.</li></ol>



<p class="wp-block-paragraph">Step four is where people lose an afternoon. Domain verification requires a TXT record in a <em>public</em> zone, because AWS queries the public name servers registered at your domain&#8217;s registrar. Put the record in a private hosted zone and verification sits in pending forever with no useful error.</p>



<pre class="wp-block-code"><code># Register the private DNS name on your endpoint service.
aws ec2 modify-vpc-endpoint-service-configuration 
  --service-id vpce-svc-EXAMPLE 
  --private-dns-name "sf-integration.example.com"

# Read back the TXT record name and value AWS expects.
aws ec2 describe-vpc-endpoint-service-configurations 
  --service-ids vpce-svc-EXAMPLE 
  --query 'ServiceConfigurations[0].PrivateDnsNameConfiguration'

# After publishing the TXT record in your PUBLIC zone:
aws ec2 start-vpc-endpoint-service-private-dns-verification 
  --service-id vpce-svc-EXAMPLE</code></pre>



<p class="wp-block-paragraph">One detail worth knowing: if verification status later changes away from verified, new connection requests are refused while existing connections keep working. So a lapsed TXT record does not break you immediately. It breaks you the next time something reconnects, which is usually during an unrelated incident.</p>



<h2 class="wp-block-heading">What this costs, mechanically</h2>



<p class="wp-block-paragraph">Rates change, so model the mechanism rather than memorizing figures from a blog post.</p>



<ul class="wp-block-list"><li><strong>Interface endpoint hours.</strong> Billed per endpoint, per Availability Zone, per hour, whether or not traffic flows. A three-AZ deployment is three billable attachments.</li><li><strong>Data processing.</strong> Per GB through the endpoint, on a tiered scale that drops as regional volume rises.</li><li><strong>Cross-AZ transfer through interface endpoints.</strong> AWS removed this charge, so multi-AZ endpoint placement is a resilience decision rather than a cost trade-off.</li><li><strong>Cross-region.</strong> Still charged, in both directions, plus a per-hour fee on the endpoint service for each remote region with attached endpoints. Match your org region to your VPC region if you can.</li><li><strong>Provider-side infrastructure.</strong> For outbound, the NLB and its targets are your bill, not PrivateLink&#8217;s.</li><li><strong>The Salesforce license.</strong> Separate from all of the above, priced per connection.</li></ul>



<p class="wp-block-paragraph">Compare honestly against what you are replacing. If the alternative is a NAT gateway carrying the same traffic, NAT has both an hourly charge and a per-GB processing charge of its own, and the comparison is often closer than people assume. If the alternative is a site-to-site VPN or a commercial network overlay, the calculus changes again. PrivateLink wins on the security posture, not automatically on the invoice.</p>



<h2 class="wp-block-heading">Troubleshooting: proving the traffic is actually private</h2>



<p class="wp-block-paragraph">Do not trust a green status field. Prove it with evidence from the data path.</p>



<ol class="wp-block-list"><li><strong>Resolve the hostname from inside the VPC.</strong> Private addresses from your endpoint subnets, or nothing else matters.</li><li><strong>Check the endpoint state.</strong> <code>aws ec2 describe-vpc-endpoints</code> should show the endpoint available, with an ENI in each subnet you expect.</li><li><strong>Read VPC flow logs.</strong> Filter for your application&#8217;s source addresses and confirm the destination is the endpoint ENI, not a NAT gateway or internet gateway address. This is the actual proof.</li><li><strong>Check the security group on the endpoint.</strong> Interface endpoints carry security groups. If 443 is not permitted from your application subnets, connections hang rather than refuse, which reads as a Salesforce problem in your logs.</li><li><strong>For outbound, sync the connection in Salesforce</strong> before believing its status. The displayed state is the last polled AWS state, not live.</li><li><strong>Test the callout through the named credential.</strong> A direct callout to a hardcoded URL will not use the private path even when everything else is correct.</li></ol>



<p class="wp-block-paragraph">Two symptom patterns worth recognizing. Connections that hang and time out point at security groups or NLB target health. Connections that fail fast with a TLS error point at certificate naming or SNI. They are diagnosed in completely different places.</p>



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



<ul class="wp-block-list"><li>Treating the setup wizard&#8217;s success message as proof that traffic is private. It proves the control plane is configured.</li><li>Creating the private hosted zone in one change and the record in another, producing NXDOMAIN for the org hostname in between.</li><li>Publishing the domain verification TXT record in a private zone instead of the public one.</li><li>Forgetting the Salesforce IAM role in the allowed principals list on the endpoint service, then debugging it as a network problem.</li><li>Sizing the connection for peak batch throughput without checking the hourly data entitlement.</li><li>Assuming sessions locked to their originating IP address will keep working. That behavior is documented as unsupported with Private Connect, along with asynchronous callouts using Apex continuations.</li><li>Building one endpoint per application VPC and discovering the license count at renewal.</li><li>Leaving callouts on hardcoded endpoints instead of named credentials, so outbound traffic never enters the private path.</li></ul>



<h2 class="wp-block-heading">Best practices for a Salesforce AWS PrivateLink rollout</h2>



<ul class="wp-block-list"><li>Inventory every integration touching the org first, with direction, protocol, hostname, and peak volume per hour. The inventory determines the design.</li><li>Centralize endpoints in a shared services VPC and route from application VPCs through Transit Gateway rather than duplicating endpoints and licenses.</li><li>Put the endpoint in the same region as the org. Cross-region private connectivity works but gives up most of the latency benefit and adds transfer charges.</li><li>Enable VPC flow logs before cutover so you have a clean before-and-after picture. Ship them somewhere you can query, whether that is CloudWatch Logs Insights, Athena over S3, or a platform like Grafana Cloud or Datadog.</li><li>Alert on the outbound hourly limit as a percentage of entitlement, not on callout failures. By the time callouts fail you are already down.</li><li>Terraform the AWS side. The endpoint, the endpoint service, the allowed principals, the private hosted zone, and the records all belong in code. The Salesforce side is click-configured, so document it in the same repository as a runbook.</li><li>Keep a documented rollback: the change that reverts you to public routing is removing or disassociating the private hosted zone, and it should be a one-step action you have practiced.</li><li>Review the configuration on a schedule. Private routing is invisible in normal operation and easy to break with an unrelated DNS or VPC change.</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">Does Private Connect encrypt my traffic?</h3>



<p class="wp-block-paragraph">Your traffic is already encrypted in transit by TLS. What private connectivity changes is the path, not the cipher. The value is that packets stay on the AWS network rather than traversing the public internet, which removes an entire class of exposure and simplifies a lot of compliance conversations. It is not a substitute for TLS, and it does not authenticate anything.</p>



<h3 class="wp-block-heading">Can I use Private Connect to access the Salesforce user interface privately?</h3>



<p class="wp-block-paragraph">No. This is API-path connectivity for integrations. Browser access to the org is a separate concern, and if that is what you need, you are looking at conditional access policies, login IP ranges, or a corporate network solution rather than PrivateLink.</p>



<h3 class="wp-block-heading">What Salesforce features actually route through an outbound connection?</h3>



<p class="wp-block-paragraph">Salesforce documents outbound support for Apex callouts, External Services, Platform Events, Change Data Capture, CRM Analytics, the OData 4.01 adapter and custom adapter for Salesforce Connect, the Salesforce Connect SQL adapter for Amazon Athena, and Agent Actions backed by External Services. Inbound covers the supported public APIs. Anything not on the current list should be verified against the documentation before you design around it, because that list grows.</p>



<h3 class="wp-block-heading">Do I need Hyperforce for this?</h3>



<p class="wp-block-paragraph">Salesforce states that Private Connect works whether the org sits in first-party data centers or on Hyperforce. That said, Hyperforce brings its own requirements, particularly the SNI behavior described above, so confirm where your org actually runs before you start debugging TLS.</p>



<h3 class="wp-block-heading">How do I connect on-premises systems, not just AWS workloads?</h3>



<p class="wp-block-paragraph">Bring the on-premises network into AWS first with Direct Connect or a site-to-site VPN, then use the interface endpoint from there. The extra work is DNS: on-premises resolvers need a Route 53 Resolver inbound endpoint to see the private hosted zone, otherwise they resolve the org hostname publicly and you are back to the first failure family.</p>



<h3 class="wp-block-heading">Can I use one connection for several Salesforce orgs?</h3>



<p class="wp-block-paragraph">No. A connection is a one-to-one mapping between an org ID and a VPC endpoint ID, so each org needs its own provisioned connection and its own license, even if they all point at the same VPC.</p>



<h3 class="wp-block-heading">Is a VPN a reasonable alternative?</h3>



<p class="wp-block-paragraph">For Salesforce specifically, no. Salesforce does not terminate a customer VPN into the org, so PrivateLink is the mechanism on offer. A VPN or a commercial overlay such as NordLayer solves a different problem, which is getting your own users and sites onto your own network. Where those tools genuinely overlap is the on-premises leg, and even there Direct Connect is usually the better answer for sustained integration traffic.</p>



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



<p class="wp-block-paragraph">A Salesforce AWS PrivateLink deployment that is misconfigured looks exactly like one that works. Same green statuses, same successful callouts, same clean application logs. The only difference is which path the packets took, and the only place that difference is visible is your flow logs.</p>



<p class="wp-block-paragraph">So build the verification step into the project rather than the postmortem. Resolve the hostname from inside the VPC, read the flow logs, confirm the destination is the endpoint ENI, and keep the check running afterwards. Everything else in this post is detail. That one habit is what separates private connectivity from a line item that says private.</p>



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



<h2 class="wp-block-heading">Need help wiring Salesforce to AWS privately?</h2>



<p class="wp-block-paragraph">This is the kind of work where a second pair of eyes on the DNS and the flow logs saves a security review later. I help teams with:</p>



<ul class="wp-block-list"><li>Designing the endpoint topology, including centralized endpoints behind Transit Gateway so you are not buying a connection per VPC.</li><li>Building and verifying the Route 53 private hosted zone override, including Resolver inbound endpoints for on-premises callers.</li><li>Standing up the endpoint service side for outbound connections: NLB, allowed principals, private DNS name registration, and domain verification.</li><li>Proving traffic is genuinely private with VPC flow log analysis, before and after cutover.</li><li>Instrumenting the hourly data entitlement so you find out about throttling from a dashboard rather than from failed callouts.</li><li>Codifying the AWS side in Terraform or OpenTofu, with a documented rollback to public routing.</li></ul>



<p class="wp-block-paragraph">If you are mid-way through one of these and something is not behaving, send me the <code>dig</code> output from inside the VPC, a flow log sample, or your endpoint service configuration and I will tell you what I see.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/salesforce-aws-privatelink/">Salesforce AWS PrivateLink: The Failures Nobody Warns You About</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<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>Nginx Rate Limiting and Bot Mitigation Without Blocking Real Users</title>
		<link>https://john-nessime.com/blog/technical-guides/nginx-rate-limiting-bot-mitigation/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 27 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Networking]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Bot Mitigation]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[DDoS Protection]]></category>
		<category><![CDATA[Fail2ban]]></category>
		<category><![CDATA[limit_req]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[Reverse Proxy]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=256</guid>

					<description><![CDATA[<p>A limit_req block that parses cleanly can still be doing absolutely nothing, or throttling every visitor behind one CDN edge. This guide covers Nginx rate limiting and bot mitigation as they actually behave in production: getting the key right behind a proxy, what burst and nodelay do to your traffic shape, sizing zones correctly, rolling out in dry run mode, and layering bot handling from robots.txt through user agent maps to firewall escalation.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/nginx-rate-limiting-bot-mitigation/">Nginx Rate Limiting and Bot Mitigation Without Blocking Real Users</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket said &#8220;site is slow, nothing changed.&#8221; Load average was climbing, PHP-FPM workers were all busy, and the access log showed the same handful of URLs being hit over and over: search queries with random terms, paginated archives forty pages deep, and a slow crawl through every tag page on the site. Nothing was down. Everything was just late.</p>



<p class="wp-block-paragraph">The fix looked obvious. Drop in a <code>limit_req_zone</code>, apply it, reload, done. Except the reload changed nothing at all, and the reason took an embarrassingly long time to find.</p>



<p class="wp-block-paragraph">This post covers Nginx rate limiting and bot mitigation the way it actually behaves in front of a real site: how the limiting modules key requests, what <code>burst</code> and <code>nodelay</code> really do to your traffic shape, why a limit that looks correct in the config can be doing nothing (or throttling everyone at once), and how to layer bot handling so you are not playing whack-a-mole with user agent strings forever.</p>



<h2 class="wp-block-heading">The failure that hides: your limit is keyed on the wrong address</h2>



<p class="wp-block-paragraph">This is the one that bites hardest, because the config parses, <code>nginx -t</code> passes, and the error log stays quiet. Nothing tells you it is wrong.</p>



<p class="wp-block-paragraph">If anything sits in front of Nginx (Cloudflare, another CDN, a load balancer, or a local Varnish tier), then <code>$remote_addr</code> is the address of that proxy, not the visitor. Key a rate limit on it and you get one of two outcomes, both bad:</p>



<ul class="wp-block-list">
<li>Every visitor arriving through a given edge node shares a single bucket. One aggressive client burns the budget and legitimate users start collecting 503s.</li>

<li>Or your rate is generous enough that the shared bucket never fills, in which case the limit is decorative and the abusive client sails straight through.</li>
</ul>



<p class="wp-block-paragraph">The real IP module fixes this, but only if you trust the right sources. It rewrites <code>$remote_addr</code> and <code>$binary_remote_addr</code> from a header, and only for connections coming from addresses you have explicitly declared trustworthy.</p>



<pre class="wp-block-code"><code># http context. One entry per CDN range you actually receive traffic from.
set_real_ip_from 203.0.113.0/24;
set_real_ip_from 2001:db8::/32;

real_ip_header CF-Connecting-IP;   # or X-Forwarded-For for a generic proxy
real_ip_recursive on;</code></pre>



<p class="wp-block-paragraph">Two things matter here more than the syntax.</p>



<p class="wp-block-paragraph">First, never widen <code>set_real_ip_from</code> to <code>0.0.0.0/0</code>. That tells Nginx to believe a client-supplied header from anyone, which means an attacker can pick a new &#8220;client IP&#8221; on every request and your per-IP limit becomes unlimited. It also poisons your logs, your <code>deny</code> rules and anything downstream that trusts the forwarded address.</p>



<p class="wp-block-paragraph">Second, after the real IP module has run, <code>$binary_remote_addr</code> holds the visitor address and <code>$realip_remote_addr</code> holds the original connecting address (the proxy). Key your limits on <code>$binary_remote_addr</code>. Keying on <code>$realip_remote_addr</code> puts you right back in the shared-bucket failure, silently.</p>



<p class="wp-block-paragraph">The proxy ranges change over time, so treat the trust list as a file that gets regenerated on a schedule and reloaded, not something you paste in once. On a self-managed VPS from a provider like Contabo or InterServer, a small cron job that fetches the current ranges, rewrites an include file, runs <code>nginx -t</code>, and only reloads on success is about twenty lines of shell and saves you from a very confusing afternoon later.</p>



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



<h2 class="wp-block-heading">What limit_req actually does, and what burst really means</h2>



<p class="wp-block-paragraph">Nginx rate limiting for requests uses a leaky bucket. The zone stores, per key, the current number of excessive requests. The rate defines how fast that excess drains. Two directives do the work: <code>limit_req_zone</code> declares the bucket in the <code>http</code> context, <code>limit_req</code> applies it in <code>http</code>, <code>server</code> or <code>location</code>.</p>



<pre class="wp-block-code"><code>http {
    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m   rate=20r/m;

    limit_req_status 429;
    limit_req_log_level warn;
}</code></pre>



<p class="wp-block-paragraph">The rate is expressed in requests per second (<code>r/s</code>) or requests per minute (<code>r/m</code>). If you want less than one request per second, use <code>r/m</code>. There is no native per-hour or per-day unit in the stock module, which surprises people writing password-reset limits.</p>



<h3 class="wp-block-heading">burst without nodelay is a queue, not a rejection</h3>



<p class="wp-block-paragraph">This is the second-most common misunderstanding after the keying problem. By default, excessive requests are <em>delayed</em> so that they come out at the configured rate. They are only rejected once the number waiting exceeds <code>burst</code>.</p>



<pre class="wp-block-code"><code>location /search/ {
    limit_req zone=general burst=20;
}</code></pre>



<p class="wp-block-paragraph">At 10r/s, a client that fires 21 requests at once gets one served immediately, nineteen held and released over roughly two seconds, and the last one rejected. Those nineteen held requests are still open connections on your server. You have not reduced concurrency, you have converted a spike into a slow trickle and paid for it in worker slots and file descriptors.</p>



<p class="wp-block-paragraph">That is genuinely useful for smoothing traffic to a fragile upstream. It is a bad idea for abuse control, where holding the connection is exactly what the attacker wants.</p>



<h3 class="wp-block-heading">nodelay, delay, and picking between them</h3>



<p class="wp-block-paragraph"><code>nodelay</code> changes the behaviour to: serve everything in the burst window immediately, reject everything past it. The bucket still drains at the configured rate, so a client that sustains the burst rate will hit the wall and stay there.</p>



<pre class="wp-block-code"><code>location /wp-login.php {
    limit_req zone=login burst=5 nodelay;
    limit_req_status 429;
    # ...fastcgi_pass or proxy_pass here
}</code></pre>



<p class="wp-block-paragraph">The <code>delay</code> parameter is the middle ground: it sets the point within the burst at which requests start being delayed rather than served immediately. <code>burst=20 delay=10</code> means the first ten excess requests go straight through, the next ten get queued, and anything beyond is rejected.</p>



<p class="wp-block-paragraph">How I decide: if a human clicking around your site could plausibly generate the spike, use <code>nodelay</code> or a <code>delay</code> that covers it. A single page load pulling twelve uncached assets through Nginx is one &#8220;request&#8221; to the user and twelve to the limiter. If the endpoint is machine-facing and expensive (search, export, login, an API write path), tighten the rate, keep the burst small, and use <code>nodelay</code> so rejection is fast and cheap.</p>



<p class="wp-block-paragraph">Also set <code>limit_req_status 429</code>. The default is 503, which tells clients and monitoring that <em>your server</em> is broken rather than that <em>they</em> are being throttled. 429 is the correct semantic, and well-behaved clients back off on it.</p>



<h3 class="wp-block-heading">Sizing the zone, and the number most guides get wrong</h3>



<p class="wp-block-paragraph">You will see &#8220;10m holds about 160,000 IP addresses&#8221; repeated everywhere. That is the 32-bit figure. Nginx documents the stored state for <code>limit_req</code> as 64 bytes on 32-bit platforms and 128 bytes on 64-bit platforms, so one megabyte holds roughly 16 thousand states on 32-bit and roughly 8 thousand on 64-bit. On the 64-bit server you are almost certainly running, a 10m zone is closer to 80,000 states.</p>



<p class="wp-block-paragraph">This matters because of what happens when the zone fills. Nginx evicts the least recently used state. If it still cannot allocate, the request is terminated with your configured error status. A zone that is constantly thrashing under a distributed crawl will start returning errors for reasons that have nothing to do with the rate you configured. If you are limiting a high-cardinality key, give the zone room.</p>



<h2 class="wp-block-heading">limit_conn solves a different problem, and you probably need both</h2>



<p class="wp-block-paragraph"><code>limit_req</code> caps how fast requests arrive. It does nothing about a client that opens sixty connections and reads each response one byte at a time. That is a concurrency problem, and it belongs to <code>limit_conn</code>.</p>



<pre class="wp-block-code"><code>http {
    limit_conn_zone $binary_remote_addr zone=perip:10m;
    limit_conn_status 429;
}

server {
    limit_conn perip 20;

    location /downloads/ {
        limit_conn perip 4;
        limit_rate_after 2m;
        limit_rate 512k;
    }
}</code></pre>



<p class="wp-block-paragraph">Three behaviours worth knowing before you pick a number.</p>



<ul class="wp-block-list">
<li>Not every connection counts. Nginx only counts a connection once the full request header has been read and the request is being processed. A client stalling mid-header is not counted here, which is why <code>client_header_timeout</code> and <code>client_body_timeout</code> still matter.</li>

<li>Under HTTP/2 and HTTP/3, each concurrent request counts as a separate connection. A limit of 4 that felt generous on HTTP/1.1 can break a modern browser loading a page with many assets. This is a very common self-inflicted outage.</li>

<li>The <code>limit_conn</code> state is smaller than the <code>limit_req</code> state, so the same zone size holds roughly twice as many entries.</li>
</ul>



<p class="wp-block-paragraph">The pairing is the point: <code>limit_req</code> protects against volume, <code>limit_conn</code> protects against occupancy. Rate limiting alone leaves you open to slow-read exhaustion, and connection limiting alone does nothing about a client making one fast request after another on a keepalive connection.</p>



<h2 class="wp-block-heading">Choosing keys: IP is a starting point, not the answer</h2>



<p class="wp-block-paragraph">The key can be any variable or combination of variables. That flexibility is where most of the value is, because per-IP limits have a real accuracy problem in both directions.</p>



<p class="wp-block-paragraph">On one side, carrier-grade NAT, corporate egress, university networks and shared VPN exit nodes (NordVPN, Surfshark and every other provider works this way) put many genuine users behind one address. A tight per-IP limit will hit them. On the other side, a distributed scraper renting a few hundred cheap addresses never touches a per-IP limit at all, because no single address is doing much.</p>



<p class="wp-block-paragraph">So layer keys instead of tuning one harder:</p>



<pre class="wp-block-code"><code>limit_req_zone $binary_remote_addr zone=perip:20m    rate=10r/s;
limit_req_zone $server_name        zone=persite:1m   rate=200r/s;</code></pre>



<p class="wp-block-paragraph">Applied together, the per-IP zone catches the loud individual and the per-site zone caps total damage regardless of how many addresses are involved. When several <code>limit_req</code> directives match, all of them are evaluated and the most restrictive result wins. If any one of them rejects, the request is rejected.</p>



<p class="wp-block-paragraph">One inheritance rule catches people out: <code>limit_req</code> directives are inherited from the enclosing level only if the current level defines none. Add a single <code>limit_req</code> inside a <code>location</code> and you have dropped every limit set at <code>server</code> level for that location. If you want both, restate both.</p>



<h3 class="wp-block-heading">Allowlisting with an empty key</h3>



<p class="wp-block-paragraph">Requests whose key evaluates to an empty string are not accounted at all. That is the clean way to exempt monitoring probes, your office, or a payment webhook source without maintaining a parallel set of locations.</p>



<pre class="wp-block-code"><code>geo $limit {
    default        1;
    10.0.0.0/8     0;
    192.0.2.10/32  0;
}

map $limit $limit_key {
    0 "";                    # empty key, never limited
    1 $binary_remote_addr;
}

limit_req_zone $limit_key zone=perip:20m rate=10r/s;</code></pre>



<p class="wp-block-paragraph">Nginx evaluates map variables only when they are used, so declaring a large map costs nothing on requests that never reference it.</p>



<h2 class="wp-block-heading">Roll it out in dry run, not in production</h2>



<p class="wp-block-paragraph">Both modules have a dry run mode. Excessive requests are counted in the shared memory zone and logged exactly as they would be, but nothing is actually rejected or delayed. Combined with the status variables, this gives you a real answer to &#8220;would this limit have hurt anyone&#8221; before you find out from a customer.</p>



<pre class="wp-block-code"><code>log_format ratelimit '$remote_addr $status "$request" '
                     'req=$limit_req_status conn=$limit_conn_status '
                     'ua="$http_user_agent"';

server {
    limit_req_dry_run  on;
    limit_conn_dry_run on;

    access_log /var/log/nginx/ratelimit.log ratelimit;
}</code></pre>



<p class="wp-block-paragraph"><code>$limit_req_status</code> reports <code>PASSED</code>, <code>DELAYED</code>, <code>REJECTED</code>, <code>DELAYED_DRY_RUN</code> or <code>REJECTED_DRY_RUN</code>. <code>$limit_conn_status</code> reports <code>PASSED</code>, <code>REJECTED</code> or <code>REJECTED_DRY_RUN</code>. A numbered rollout that has never embarrassed me:</p>



<ol class="wp-block-list">
<li>Add the zones and the <code>limit_req</code> directives with dry run on, plus the log format above.</li>

<li>Run for a full traffic cycle. A week if you have weekly patterns, longer if you have a monthly billing spike.</li>

<li>Count the would-be rejections by user agent and by address. If real browsers appear, your rate or burst is wrong, not your users.</li>

<li>Turn dry run off on the least critical location first. Watch 429 rates alongside your normal error budget.</li>

<li>Only then apply it to login, checkout, or anything that costs you money when it breaks.</li>
</ol>



<p class="wp-block-paragraph">Shipping the rate limit log into Loki and graphing it in Grafana makes step three take minutes instead of an evening with <code>awk</code>. Any log platform with decent label cardinality handling works; the point is having rejection counts broken down by zone and user agent, over time, next to your latency graphs.</p>



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



<h2 class="wp-block-heading">Bot mitigation in layers, cheapest first</h2>



<p class="wp-block-paragraph">Rate limiting treats all clients the same. Bot mitigation is about deciding that some clients deserve different treatment before the limiter ever sees them. The useful mental model is a set of layers ordered by cost and by how easy each is to defeat.</p>



<h3 class="wp-block-heading">Layer one: robots.txt, and being honest about what it does</h3>



<p class="wp-block-paragraph">robots.txt is a request, not a control. Crawlers that respect it will honour a <code>Disallow</code> on your search endpoint, your faceted filter URLs and your infinite calendar pages, and that alone removes a surprising amount of load. Crawlers that do not respect it will ignore the file completely, and some will read it specifically to find out where the interesting URLs are.</p>



<p class="wp-block-paragraph">Use it to shape well-behaved traffic. Never use it as a security boundary. And be careful about blanket disallows on search engines you actually want: deindexing yourself is a much more expensive mistake than a bit of crawler load.</p>



<h3 class="wp-block-heading">Layer two: user agent classification with map</h3>



<p class="wp-block-paragraph">A <code>map</code> on <code>$http_user_agent</code> is cheap, readable, and easy to revise. It is also trivially spoofable, so treat it as traffic shaping rather than enforcement.</p>



<pre class="wp-block-code"><code>map $http_user_agent $ua_class {
    default          human;
    ""               empty;
    "~*bot|crawler|spider|scrape"  bot;
}

map $ua_class $bot_limit_key {
    bot   $binary_remote_addr;
    empty $binary_remote_addr;
    human "";
}

limit_req_zone $bot_limit_key zone=bots:10m rate=30r/m;</code></pre>



<p class="wp-block-paragraph">That gives declared bots a much tighter budget than browsers while leaving human traffic on the general limit. An empty user agent is worth its own class: almost nothing legitimate sends one, and it is a reliable signal for cheap scraping tools.</p>



<p class="wp-block-paragraph">Two cautions. Broad substring matching on <code>bot</code> will catch strings you did not intend, so review a sample of your own logs before deploying the pattern. And blocking rather than throttling is a bigger decision than it looks: if you return 403 to a crawler you actually want, you find out weeks later in your search traffic, not in your error log.</p>



<h3 class="wp-block-heading">Layer three: verifying bots that claim a name</h3>



<p class="wp-block-paragraph">The interesting case is a request claiming to be a major search crawler. Impersonating one is the standard way to bypass a user agent based allowlist, so a claim needs verification.</p>



<p class="wp-block-paragraph">The method the search vendors themselves document is a forward-confirmed reverse DNS check: reverse-resolve the client address, confirm the resulting hostname belongs to the vendor&#8217;s domain, then forward-resolve that hostname and confirm it returns the original address. An attacker cannot fake this without controlling the vendor&#8217;s DNS. Several vendors also publish their crawler IP ranges as machine-readable lists, which avoids the DNS round trip entirely.</p>



<p class="wp-block-paragraph">Here is the honest limitation: stock Nginx has no directive that does this. There is no built-in reverse DNS verification in the core modules. Your realistic options are a third-party module, doing the verification out of band and feeding the result into a <code>geo</code> block or an included allowlist file, or pushing the decision up to a CDN or WAF layer that already does it. Anyone showing you a pure stock-Nginx config that &#8220;verifies Googlebot&#8221; is verifying a string, not an identity.</p>



<p class="wp-block-paragraph">This is also the point where an edge provider earns its keep. Cloudflare, Fastly and similar edge platforms maintain verified bot lists and fingerprinting that no single origin server can reproduce, and they drop the traffic before it costs you a worker. If your bot problem is large and distributed, the edge is the right layer and Nginx is your backstop, not your front line.</p>



<h3 class="wp-block-heading">Layer four: escalation to the firewall</h3>



<p class="wp-block-paragraph">A client that collects hundreds of 429s and keeps going is not going to stop. Serving it a rejection still costs you a TCP handshake, a TLS negotiation and a log line. At that point the cheapest response is to stop answering at all.</p>



<p class="wp-block-paragraph">Fail2ban is the usual tool: a filter that matches your rate limit rejections in the Nginx error or access log, an action that inserts a firewall drop, and a ban time long enough to be inconvenient. Two things to get right. Match on a pattern that only fires for real limit rejections, or you will ban yourself during a deploy. And if you are behind a CDN, your firewall sees the CDN address, not the client, so the ban has to be applied at the edge instead. Banning a Cloudflare range at your origin firewall takes your whole site offline, and it is a mistake people make exactly once.</p>



<p class="wp-block-paragraph">Return code <code>444</code> is worth knowing here. It is Nginx-specific and closes the connection without any response, which is cheaper than generating an error page for a client that will never read it.</p>



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



<p class="wp-block-paragraph"><strong>The limit does nothing.</strong> Check the key first. Log <code>$binary_remote_addr</code> alongside <code>$realip_remote_addr</code> and confirm they differ in the way you expect. Then check inheritance: a <code>limit_req</code> in the matched <code>location</code> discards the <code>server</code> level ones. Confirm which location actually matched by adding <code>$request_uri</code> and a marker header while testing.</p>



<p class="wp-block-paragraph"><strong>Real users are getting 429 or 503.</strong> Turn dry run back on and read the log rather than guessing. Usually it is one of three things: a burst too small for a normal page load&#8217;s asset count, a <code>limit_conn</code> value set for HTTP/1.1 assumptions on an HTTP/2 server, or a shared-egress network where many people genuinely share one address.</p>



<p class="wp-block-paragraph"><strong>Nothing appears in the error log.</strong> The default log level for rate limit refusals is <code>error</code>, and delays are logged one level below whatever you set. If you set <code>limit_req_log_level warn</code>, delays go to <code>info</code>, which your log level may be filtering out entirely. Grep for the limiting messages directly:</p>



<pre class="wp-block-code"><code>grep 'limiting requests' /var/log/nginx/error.log | tail -50
grep 'limiting connections' /var/log/nginx/error.log | tail -50</code></pre>



<p class="wp-block-paragraph"><strong>Intermittent errors under heavy load with no matching rate.</strong> Suspect zone exhaustion. Enlarge the zone and see whether the pattern changes. High-cardinality keys and distributed crawls are the usual cause.</p>



<p class="wp-block-paragraph"><strong>Health checks and uptime probes are being throttled.</strong> Add them to the allowlist map. Monitoring hitting a rate limit produces alerts about an outage that is not happening, which is worse than no monitoring.</p>



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



<ul class="wp-block-list">
<li>Trusting forwarded headers from every source, which turns a per-IP limit into no limit at all.</li>

<li>Leaving <code>limit_req_status</code> at the default 503, so throttling looks like an outage in your own monitoring.</li>

<li>Using <code>burst</code> without <code>nodelay</code> on an abuse path, and quietly filling worker slots with held connections.</li>

<li>Setting a <code>limit_conn</code> value based on HTTP/1.1 intuition on a server serving HTTP/2 or HTTP/3.</li>

<li>Adding one <code>limit_req</code> in a location and silently dropping every server-level limit for that path.</li>

<li>Blocking user agent substrings without checking a sample of real logs first.</li>

<li>Applying identical limits to reads and writes. A cacheable GET and a POST that writes to the database do not deserve the same budget.</li>

<li>Banning CDN address ranges at the origin firewall because the logs show them as the client.</li>
</ul>



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



<ul class="wp-block-list">
<li>Verify the key before tuning the rate. Every other decision depends on it being correct.</li>

<li>Always start in dry run and measure a full traffic cycle before enforcing.</li>

<li>Use separate zones per traffic class. Search, login, API, static and general traffic have different shapes and should not share a bucket.</li>

<li>Pair <code>limit_req</code> with <code>limit_conn</code>. The first caps arrival rate, the second caps occupancy, and neither substitutes for the other.</li>

<li>Layer a per-server zone above your per-IP zone so distributed traffic still hits a ceiling.</li>

<li>Allowlist with an empty key rather than duplicating location blocks.</li>

<li>Export <code>$limit_req_status</code> to your log platform and graph rejections next to latency and upstream errors.</li>

<li>Keep CDN trust ranges in a generated include file that is refreshed on a schedule and validated with <code>nginx -t</code> before reload.</li>

<li>Throttle before you block. Blocking failures are invisible until they show up in your search traffic.</li>
</ul>



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



<h3 class="wp-block-heading">Does Nginx rate limiting stop a DDoS attack?</h3>



<p class="wp-block-paragraph">It helps with application-layer floods that reach your server, because rejecting a request is far cheaper than processing it. It does nothing about volumetric attacks that saturate your network link, since that traffic never gets to Nginx to be limited. For that you need capacity in front of the origin: a CDN or scrubbing service. Treat rate limiting as protection against abuse and accidental floods, not as DDoS mitigation.</p>



<h3 class="wp-block-heading">What rate should I actually set?</h3>



<p class="wp-block-paragraph">There is no universal number, and any guide that gives you one has not seen your traffic. Measure instead: run dry run mode, look at your real per-IP request distribution, and set the rate above the busiest legitimate client with headroom. Then set a separate, much tighter zone for the specific endpoints that are expensive or attacked.</p>



<h3 class="wp-block-heading">Should I use 429 or 503 for rejected requests?</h3>



<p class="wp-block-paragraph">429 in nearly every case. It communicates that the client should slow down, well-behaved clients back off on it, and it keeps your 5xx metrics meaningful. The default is 503 purely for historical reasons.</p>



<h3 class="wp-block-heading">Can Nginx rate limit per hour or per day?</h3>



<p class="wp-block-paragraph">Not with the stock module. It accepts requests per second and requests per minute only, so the slowest native rate is one request per minute expressed as <code>1r/m</code>. Longer windows need a third-party module, a shared store such as Redis via a scripting module, or enforcement in the application itself. For something like a password reset limit, the application is usually the better place anyway, because it can key on the account rather than the address.</p>



<h3 class="wp-block-heading">Do rate limit zones work across multiple Nginx servers?</h3>



<p class="wp-block-paragraph">Not in the open source build. Each instance keeps its own shared memory zone, so a client spread across four load-balanced servers effectively gets four times the budget. The <code>sync</code> parameter on <code>limit_req_zone</code> synchronises state across a cluster, but it is part of the commercial subscription. Without it, either divide your intended rate by your instance count, ensure your balancer pins clients consistently, or move the limit to a shared store.</p>



<h3 class="wp-block-heading">Will blocking AI crawlers hurt my SEO?</h3>



<p class="wp-block-paragraph">Blocking training crawlers is separate from blocking search indexing crawlers, and the distinction is worth checking per vendor before you write a rule, because some vendors run several crawlers with different purposes under related names. The risk is collateral damage: a broad user agent pattern that also matches a search crawler will remove you from results, and you will not notice quickly. Verify your pattern against real log samples, and prefer throttling over outright blocking while you are unsure.</p>



<h3 class="wp-block-heading">Where should rate limiting live: the CDN, Nginx, or the application?</h3>



<p class="wp-block-paragraph">All three, doing different jobs. The CDN handles volume and known-bad reputation before it costs you anything. Nginx handles per-client shaping and protects your workers from anything that gets through. The application handles limits that need identity or business context, like per-account quotas, which Nginx cannot see. Skipping the Nginx layer because you have a CDN leaves you exposed the moment someone finds your origin address.</p>



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



<p class="wp-block-paragraph">Nginx rate limiting is not hard to configure. It is easy to configure into a state where it silently does nothing, or silently punishes the wrong people, and both failures look identical from the outside: a config that parses, a clean <code>nginx -t</code>, and no complaints until there are.</p>



<p class="wp-block-paragraph">So get the key right, prove it with a log line rather than assuming it, and run every new limit in dry run until the data tells you it is safe. Layer bot handling by cost, throttle before you block, and be honest about which layer can actually verify an identity. Everything else is tuning, and tuning is easy once the foundation is not lying to you.</p>



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



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



<p class="wp-block-paragraph">Most of the work here is diagnostic rather than clever. Things I regularly help with:</p>



<ul class="wp-block-list">
<li>Auditing an existing <code>limit_req</code> and <code>limit_conn</code> setup to find out whether it is actually limiting anything, including the keying and inheritance traps above.</li>

<li>Fixing real client IP handling behind Cloudflare or another CDN, with a generated trust list that refreshes safely instead of drifting.</li>

<li>Designing per-endpoint zones for WordPress, WooCommerce or API origins so login, search and write paths get budgets that match their real cost.</li>

<li>Running a measured dry run rollout and reporting back what would have been rejected, broken down by user agent and network, before anything is enforced.</li>

<li>Building bot classification and escalation: user agent maps, allowlists, Fail2ban integration, and knowing which decisions belong at the edge instead.</li>

<li>Wiring rate limit telemetry into Grafana and Loki so throttling is something you can see on a dashboard rather than discover from a ticket.</li>
</ul>



<p class="wp-block-paragraph">If you would like a look at yours, send me the relevant server block, a sample of the access log, and whatever is currently going wrong. That is usually enough to say something useful straight away.</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/nginx-rate-limiting-bot-mitigation/">Nginx Rate Limiting and Bot Mitigation Without Blocking Real Users</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>ALB vs API Gateway: Pick the Limits You Can Live With</title>
		<link>https://john-nessime.com/blog/devops/alb-vs-api-gateway/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 25 Aug 2026 09:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Networking]]></category>
		<category><![CDATA[API Gateway]]></category>
		<category><![CDATA[Application Load Balancer]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[JWKS]]></category>
		<category><![CDATA[JWT Validation]]></category>
		<category><![CDATA[Listener Rules]]></category>
		<category><![CDATA[Load Balancing]]></category>
		<category><![CDATA[OIDC]]></category>
		<category><![CDATA[Private Integration]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[REST API]]></category>
		<category><![CDATA[Serverless]]></category>
		<category><![CDATA[Web Application Firewall]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=550</guid>

					<description><![CDATA[<p>Choosing between an Application Load Balancer and API Gateway is not a feature comparison. It is a choice of hard limits, a billing curve and an authentication story. Here is how each one bills you, which constraints surface months later, what ALB's native JWT validation changes, and a decision procedure you can run in ten minutes.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/alb-vs-api-gateway/">ALB vs API Gateway: Pick the Limits You Can Live With</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket said the reporting endpoint returns a 504 after about thirty seconds. The application log said the job finished in forty-one seconds and wrote a perfectly good response to a client that had already hung up. Nothing crashed, nothing scaled wrong, nobody deployed anything that week.</p>



<p class="wp-block-paragraph">The integration timeout had been sitting in front of that API since the day someone created it. It only became a bug when a customer&#8217;s dataset got big enough to cross it.</p>



<p class="wp-block-paragraph">That is the real shape of the ALB vs API Gateway decision. You&#8217;re not picking a feature list. You&#8217;re picking a set of hard limits, a billing curve and an authentication story, then living inside them. The failure mode that bites isn&#8217;t a bad Friday, it&#8217;s finding the constraint months later, once the thing in front of your app has become load-bearing.</p>



<p class="wp-block-paragraph">Below: how each one bills you, which limits surface late, what changed recently on the load balancer side, and a decision procedure.</p>



<h2 class="wp-block-heading">The old rule of thumb is shakier than it looks</h2>



<p class="wp-block-paragraph">The advice you find everywhere is: containers and instances behind an Application Load Balancer, serverless behind API Gateway. Wrong often enough to cost you. ALB takes Lambda functions as targets. API Gateway reaches containers through private integrations with an NLB, an ALB, or Cloud Map. Compute type barely narrows the field.</p>



<p class="wp-block-paragraph">What separates them is who&#8217;s calling and what needs enforcing before a request reaches your code. A browser session against your own web app is a different problem from a partner&#8217;s server calling a documented endpoint with a key you issued and a quota you promised. One needs routing and a session. The other needs a contract.</p>



<h2 class="wp-block-heading">Application Load Balancer: routing you own the position of</h2>



<p class="wp-block-paragraph">ALB is a Layer 7 router inside your VPC. You give it subnets and security groups, attach listeners, and write rules that match on host, path, header, query string, method or source IP, then forward, redirect, return a fixed response, or authenticate.</p>



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



<ul class="wp-block-list">
<li><strong>Long requests.</strong> The idle timeout is an attribute you set, defaulting to 60 seconds. Report generation and slow third-party calls become a number in Terraform instead of an architecture problem.</li>

<li><strong>Big bodies and streaming.</strong> Against instance, IP and container targets there&#8217;s no small payload ceiling to design around, and WebSocket upgrades pass through.</li>

<li><strong>Browser authentication.</strong> An HTTPS listener rule can run an <code>authenticate-oidc</code> or <code>authenticate-cognito</code> action before forwarding. The load balancer does the redirect dance, sets a session cookie, and hands your target the verified claims in a header. Your app reads a header instead of implementing OAuth.</li>
</ul>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>No per-client rate limiting.</strong> No API keys with quotas attached. Rate limiting means AWS WAF rate-based rules in front, counting requests per source over a window. Blunter than a per-consumer plan, and a separate bill.</li>

<li><strong>No request validation or response caching.</strong> A malformed body reaches your handler and burns your compute. A cacheable GET is recomputed unless you put a CDN in front.</li>

<li><strong>Lambda targets are constrained.</strong> Request body capped at 1 MB, response JSON capped at 1 MB, WebSocket upgrades rejected with a 400. If your function returns a large document, that ceiling arrives as a 502 in production, not an error at deploy time.</li>

<li><strong>You pay for it while it sleeps.</strong> An hourly charge per load balancer whether or not a request arrives. Three environments, three floors.</li>
</ul>



<h2 class="wp-block-heading">Amazon API Gateway: the contract layer</h2>



<p class="wp-block-paragraph">API Gateway isn&#8217;t a load balancer with extra features. It&#8217;s a place to define what your API promises: which routes exist, who may call them, how often, and in what shape. That framing explains most of its trade-offs.</p>



<p class="wp-block-paragraph">Choosing it is really two decisions, because REST APIs and HTTP APIs are different products under one name.</p>



<ul class="wp-block-list">
<li><strong>REST APIs</strong> carry the full management surface: API keys, usage plans, per-client throttling, request validation, response caching, AWS WAF integration, the private endpoint type, execution logs and X-Ray tracing.</li>

<li><strong>HTTP APIs</strong> are the stripped-down, cheaper option. They add a built-in JWT authorizer, automatic deployments and Cloud Map private integrations. They offer none of the six management features listed above.</li>
</ul>



<p class="wp-block-paragraph">That second line is the one people get wrong. HTTP APIs aren&#8217;t &#8220;REST APIs but cheaper&#8221;, they&#8217;re a different product that happens to serve HTTP. Pick them to save money, then discover you need metered access, and you&#8217;re rebuilding usage plans in your own code.</p>



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



<ul class="wp-block-list">
<li><strong>You&#8217;re selling or governing access.</strong> API keys tied to usage plans, on REST APIs, are the only native answer to &#8220;customer B must not burn customer A&#8217;s capacity&#8221;.</li>

<li><strong>Rejecting bad requests before they cost you.</strong> Request validation against a model means a malformed payload never invokes your function. On per-invocation compute that&#8217;s a security boundary and a cost lever at once.</li>

<li><strong>Zero idle cost.</strong> No hourly floor, so a dev stage nobody calls is free. For genuinely spiky traffic this is often the whole argument, and there&#8217;s no network to design either.</li>
</ul>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>The integration timeout.</strong> The default is 29 seconds. It can be raised, but AWS notes that going beyond 29 seconds may require reducing your Region-level throttle quota. You&#8217;re buying request duration with request rate.</li>

<li><strong>Payload ceilings.</strong> There&#8217;s a documented maximum payload size, well below what a file upload service wants. Large objects want presigned S3 URLs either way, but here the gateway forces that conversation early.</li>

<li><strong>Cost scales with request count.</strong> Per request plus data transfer. A health check hammered by a monitoring tool shows up directly on the line item.</li>

<li><strong>The account throttle is shared.</strong> The default steady-state rate and burst quota applies per Region across your APIs in that account. Raisable, but a shared ceiling by default rather than a per-API one.</li>
</ul>



<h2 class="wp-block-heading">What changed: the load balancer validates JWTs itself now</h2>



<p class="wp-block-paragraph">For years the clean argument for API Gateway on machine-to-machine APIs was token validation. If a partner&#8217;s service calls you with a bearer token, someone has to check signature, issuer and expiry, and you&#8217;d rather that not be a library duplicated in every service.</p>



<p class="wp-block-paragraph">ALB does this natively now. A rule on an HTTPS listener can carry a <code>jwt-validation</code> action that runs before any routing action. The load balancer fetches signing keys from a JWKS endpoint you configure, checks the signature, and requires <code>iss</code> and <code>exp</code>. It also validates <code>nbf</code> and <code>iat</code> when present, and you can configure up to ten additional claims. Valid tokens forward unchanged. Invalid ones never reach your VPC.</p>



<p class="wp-block-paragraph">This isn&#8217;t <code>authenticate-oidc</code>, which is built for humans in browsers and works by redirecting. JWT validation does no redirect. It inspects the token and returns a yes or no, which is what service-to-service traffic needs. Validation action first, routing action second, and order matters:</p>



<pre class="wp-block-code"><code>aws elbv2 create-rule 
  --listener-arn "$LISTENER_ARN" 
  --priority 10 
  --conditions Field=path-pattern,Values="/api/*" 
  --actions '[
    {
      "Type": "jwt-validation",
      "JwtValidationConfig": {
        "JwksEndpoint": "https://issuer.example.com/.well-known/jwks.json",
        "Issuer": "https://issuer.example.com"
      },
      "Order": 1
    },
    {
      "Type": "forward",
      "TargetGroupArn": "$TARGET_GROUP_ARN",
      "Order": 2
    }
  ]'</code></pre>



<p class="wp-block-paragraph">Two constraints first. Only RS256 is supported, and the JWKS endpoint has a maximum response size and key count. A provider publishing a large key set fails that check, and the failure mode is that requests stop reaching your targets. Fetch your provider&#8217;s JWKS document and look at it before committing.</p>



<p class="wp-block-paragraph">So &#8220;we need token validation at the edge&#8221; is no longer by itself a reason to reach for API Gateway. What the gateway still owns is everything downstream of that yes or no: keys, quotas, per-consumer throttles, shape validation, caching.</p>



<h2 class="wp-block-heading">How each one actually bills you</h2>



<p class="wp-block-paragraph">This is where the argument usually goes wrong, because people compare unit prices instead of curve shapes.</p>



<p class="wp-block-paragraph">API Gateway is a slope with no floor: per request, plus data out. Zero traffic costs nothing, ten times the traffic costs roughly ten times as much.</p>



<p class="wp-block-paragraph">ALB is a floor plus a stranger slope. An hourly rate per load balancer, plus capacity units measured across four dimensions at once:</p>



<ul class="wp-block-list">
<li>New connections per second</li>

<li>Active connections per minute</li>

<li>Processed bytes per hour</li>

<li>Rule evaluations per second, where the first ten processed rules per request are free</li>
</ul>



<p class="wp-block-paragraph">You&#8217;re charged on the highest of the four in a given hour, not the sum. That mechanic explains most surprises. Clients that open a fresh connection per call can bill you on new connections while your bandwidth graph looks flat. A listener with dozens of path rules can bill you on rule evaluations while everything else idles.</p>



<p class="wp-block-paragraph">Two details worth knowing: Lambda targets get a smaller processed-bytes allowance per capacity unit than instance, container and IP targets, so identical traffic consumes more units. Enabling mutual TLS reduces the active-connections allowance per unit.</p>



<p class="wp-block-paragraph">Before modelling anything, look at what you already consume:</p>



<pre class="wp-block-code"><code>aws cloudwatch get-metric-statistics 
  --namespace AWS/ApplicationELB 
  --metric-name ConsumedLCUs 
  --dimensions Name=LoadBalancer,Value=app/my-alb/0123456789abcdef 
  --start-time "$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)" 
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" 
  --period 3600 
  --statistics Sum Maximum</code></pre>



<p class="wp-block-paragraph">Run that against a representative week and you have the real input. For API Gateway, the equivalent is request count per stage. Across several accounts, a cost visibility tool like Vantage or CloudZero attributes this per environment faster than Cost Explorer, though disciplined tagging gets you most of the way for free.</p>



<p class="wp-block-paragraph">Rough shape: low steady traffic favours API Gateway because you skip the floor. High-volume, byte-heavy, long-lived-connection traffic favours ALB because the per-request slope disappears. Chatty traffic with tiny payloads is where the curves cross, and that one is worth calculating.</p>



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



<h2 class="wp-block-heading">A decision procedure for ALB vs API Gateway</h2>



<p class="wp-block-paragraph">Run these in order, stop at the first clear answer.</p>



<ol class="wp-block-list">
<li><strong>Does any request legitimately run past about thirty seconds?</strong> If yes and you can&#8217;t make it asynchronous, ALB. Raising the gateway timeout is possible but trades against your regional throttle quota.</li>

<li><strong>Do you need per-consumer quotas or metered access?</strong> If yes, API Gateway REST APIs. Nothing else here gives you keys and usage plans natively.</li>

<li><strong>Do requests or responses carry large payloads?</strong> If yes, ALB with instance or container targets. Both options constrain size, but the Lambda-target and gateway ceilings arrive sooner.</li>

<li><strong>Is traffic occasional, spiky, or mostly zero?</strong> If yes, API Gateway. An hourly floor for a few thousand requests a day is real cost with no matching benefit.</li>

<li><strong>Is the caller a browser session against your own app?</strong> If yes, ALB, with an authenticate action if you need login. The gateway&#8217;s management features are dead weight here.</li>

<li><strong>Is the caller another service with a bearer token and nothing more?</strong> Either works now. Decide on cost curve and payload shape, because JWT validation stopped being the tiebreaker.</li>
</ol>



<p class="wp-block-paragraph">Still torn? Choose the limits you&#8217;d rather explain to a customer in a year. Usually ALB for applications, API Gateway for products. Whichever you pick, alarm on the constraint rather than the symptom: integration latency approaching the timeout, or consumed capacity units trending up. Grafana Cloud or plain CloudWatch alarms both do the job. Then send a payload just over your ceiling in staging and confirm what the client actually receives.</p>



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



<h3 class="wp-block-heading">&#8220;It&#8217;s serverless, so it&#8217;s cheaper&#8221;</h3>



<p class="wp-block-paragraph">Per-request billing is cheaper at low volume and more expensive at high volume. Arithmetic, not philosophy. The crossover depends on your request size and connection reuse, which is why the capacity-unit metric matters more than anyone&#8217;s price table.</p>



<h3 class="wp-block-heading">&#8220;We&#8217;ll put API Gateway in front of it later&#8221;</h3>



<p class="wp-block-paragraph">You can, through a private integration, and it&#8217;s a legitimate pattern. But &#8220;later&#8221; means a second hop, a second bill, a second set of timeouts and a second place to look during an incident. Adding it deliberately is fine. Assuming it&#8217;s free is not.</p>



<h3 class="wp-block-heading">&#8220;ALB has no rate limiting, so it&#8217;s insecure&#8221;</h3>



<p class="wp-block-paragraph">ALB has no <em>native</em> rate limiting, but WAF rate-based rules in front of it are a real control, and a source-based limit is often closer to what you wanted than a per-key quota. Cloudflare in front of the load balancer is another common answer when you already want its caching and bot controls. What ALB genuinely lacks is the per-consumer contract, which matters when consumers are customers and doesn&#8217;t when they&#8217;re your own front end.</p>



<p class="wp-block-paragraph">Check the pairing before you rely on it, though: WAF associates with ALB and with API Gateway REST APIs, but not with HTTP APIs. And the amount of request body WAF reads is configurable for some resource types and fixed for the load balancer. Anything past that window isn&#8217;t scanned.</p>



<h3 class="wp-block-heading">&#8220;The front door has to be an AWS service&#8221;</h3>



<p class="wp-block-paragraph">For one service with predictable traffic, a plain VPS running Nginx or Caddy does the same job with far less surface. Providers like Contabo or InterServer are reasonable homes for a small internal API. It won&#8217;t scale into a multi-service platform and it moves patching and certificate renewal onto you, but &#8220;which AWS service&#8221; is sometimes the wrong question.</p>



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



<h3 class="wp-block-heading">Can I use API Gateway and ALB together?</h3>



<p class="wp-block-paragraph">Yes. API Gateway supports private integrations with an Application Load Balancer, so the gateway handles keys, quotas and validation while the load balancer routes inside the VPC. Sound pattern for a public product API in front of an existing internal platform. It also doubles the hops, the bills and the places a request can die, so use it when you need both jobs done.</p>



<h3 class="wp-block-heading">Which is cheaper, ALB or API Gateway?</h3>



<p class="wp-block-paragraph">Neither, universally. API Gateway has no idle cost and charges per request, so it wins at low or intermittent volume. ALB charges an hourly floor plus capacity units billed on the highest of four dimensions, so it wins once the per-request slope dominates. Pull your consumed capacity units and request counts and do the arithmetic on your own traffic.</p>



<h3 class="wp-block-heading">Can ALB do rate limiting?</h3>



<p class="wp-block-paragraph">Not natively. You get it by associating AWS WAF with the load balancer and using rate-based rules, which limit by source over a time window. If you need per-key quotas, that&#8217;s an API Gateway REST API usage plan, or logic you write yourself.</p>



<h3 class="wp-block-heading">Does ALB support JWT authentication now?</h3>



<p class="wp-block-paragraph">Yes, through a <code>jwt-validation</code> action on an HTTPS listener rule. It verifies the signature against a JWKS endpoint and checks issuer and expiry, plus optional additional claims. Note the RS256-only restriction and the JWKS size and key count limits before designing around it.</p>



<h3 class="wp-block-heading">Should I use HTTP APIs or REST APIs?</h3>



<p class="wp-block-paragraph">REST APIs if you need any of: API keys, usage plans, per-client throttling, request validation, response caching, WAF integration, or a private endpoint type. HTTP APIs when you need none of those and want the built-in JWT authorizer at a lower price. Migrating between them isn&#8217;t a config flag, so decide before you publish a URL.</p>



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



<p class="wp-block-paragraph">ALB vs API Gateway isn&#8217;t a question about serverless versus containers, and it stopped being a question about token validation. It&#8217;s a question about whether the thing in front of your application needs to enforce a contract with people outside your organisation.</p>



<p class="wp-block-paragraph">If it does, take the gateway and accept its timeout and payload ceilings as the price of keys, quotas and validation. If it doesn&#8217;t, take the load balancer and spend the saved effort on the network and the health checks. Either way, write down the three numbers that will eventually bite: timeout, payload ceiling, throttle quota. The 504 in that opening ticket wasn&#8217;t a bug. It was a decision nobody had written down.</p>



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



<h2 class="wp-block-heading">Need a second opinion on your front door?</h2>



<p class="wp-block-paragraph">Cheap to get right at the start, expensive to revisit. If you&#8217;re staring at this now, or already living with the wrong answer:</p>



<ul class="wp-block-list">
<li>Modelling ALB capacity units against API Gateway request counts on your actual traffic, so the comparison uses your numbers</li>

<li>Auditing an existing gateway or load balancer for the limits you&#8217;re closest to hitting: timeouts, payload ceilings, throttle quotas</li>

<li>Moving browser authentication off your application onto an ALB authenticate rule, or setting up JWT validation at the listener for service-to-service traffic</li>

<li>Designing the gateway plus private integration pattern when you need both layers, including where to terminate TLS and how to preserve client IPs</li>

<li>Building the Terraform or OpenTofu modules for either option, with listener rules, target groups, health checks and alarms in one place</li>

<li>Setting up alarms that fire on approaching limits rather than the 5XX that follows</li>
</ul>



<p class="wp-block-paragraph">Send me a listener rule dump, a stage configuration, or a week of consumed capacity unit metrics and I&#8217;ll tell you what I see.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/alb-vs-api-gateway/">ALB vs API Gateway: Pick the Limits You Can Live With</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>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>
