<?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>CDN | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/cdn/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/cdn/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sat, 19 Sep 2026 14:51:00 +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>CDN | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/cdn/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</title>
		<link>https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 10 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Residency]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[Presigned URLs]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=382</guid>

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



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



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



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



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



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



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



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



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



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



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

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

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

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

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



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



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

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

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

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



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



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



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

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

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

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

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



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



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

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

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

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



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



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



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



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



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



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



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

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

# Bytes
du -sh wp-content/uploads

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



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



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



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



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



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



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



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



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



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



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

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



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



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



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



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

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



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



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



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



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



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



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



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

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



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



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



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

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

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

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

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

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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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



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

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

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

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

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

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



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



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>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>A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</title>
		<link>https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 03 Aug 2026 12:33:00 +0000</pubDate>
				<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[CLS]]></category>
		<category><![CDATA[Core Web Vitals]]></category>
		<category><![CDATA[INP]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[LCP]]></category>
		<category><![CDATA[PageSpeed Insights]]></category>
		<category><![CDATA[SEO]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[Website Speed]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=95</guid>

					<description><![CDATA[<p>You got PageSpeed Insights to 98 and six weeks later Search Console still says the same URLs are failing. The score and the assessment measure different things, and Lighthouse cannot measure INP at all. Here is how to fix Core Web Vitals on WordPress in the order that actually moves the report.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/">A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You spent a fortnight on it. Deferred the scripts, converted everything to WebP, turned on a caching plugin, tuned the CDN. PageSpeed Insights now returns 98 on desktop and 92 on mobile, and you screenshot it for the client.</p>



<p class="wp-block-paragraph">Six weeks later the Core Web Vitals report in Search Console still says the same URLs are failing. Nothing moved.</p>



<p class="wp-block-paragraph">Here is why, and it is the single most important thing to understand before touching anything: <strong>the score and the assessment are two different measurements of two different things.</strong> The score is Lighthouse running a simulated page load on throttled hardware, once, from wherever you are. The assessment is Chrome telemetry from real visitors on real devices, aggregated over 28 days, graded at the 75th percentile.</p>



<p class="wp-block-paragraph">Worse: Lighthouse cannot measure one of the three metrics at all. Interaction to Next Paint requires an actual human interacting with the page, so no lab tool produces it. You can score 100 and fail INP for every mobile visitor you have.</p>



<p class="wp-block-paragraph">This walks through fixing <strong>Core Web Vitals on WordPress</strong> the way that actually shifts the report: get field data first, then work each metric in the order that pays.</p>



<h2 class="wp-block-heading">Field data first, or you are guessing</h2>



<p class="wp-block-paragraph">Three facts that determine everything else.</p>



<p class="wp-block-paragraph"><strong>You are graded at the 75th percentile.</strong> Three quarters of page views need a good result. Not the median, not your laptop. The fourth-slowest visitor out of four, typically a mid-range Android phone on patchy mobile data, decides whether you pass.</p>



<p class="wp-block-paragraph"><strong>The window is 28 days and it rolls.</strong> Deploy a fix today and the report will not reflect it for weeks, and even then it moves gradually as old data ages out. Anyone promising you a same-week improvement in Search Console does not understand the mechanism.</p>



<p class="wp-block-paragraph"><strong>Thin URLs fall back to origin-level data.</strong> If a page has too few visits for its own dataset, Google uses a group or your whole origin instead. So one badly performing template can drag URLs that are individually fine.</p>



<p class="wp-block-paragraph">Practically: start in the Core Web Vitals report in Search Console to see which templates fail and how many URLs are affected. Then run PageSpeed Insights on a representative URL and read <em>only the field data section at the top</em> to decide what is broken. Use the Lighthouse section below it to work out why, which is what lab data is genuinely good for.</p>



<p class="wp-block-paragraph">If you want faster feedback than 28 days, add real user monitoring. Google&#8217;s <code>web-vitals</code> JavaScript library reports all three metrics from your own visitors and you can send them wherever you like. On a site where performance matters commercially, this is worth the afternoon it takes.</p>



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



<h2 class="wp-block-heading">LCP: the biggest thing on screen</h2>



<p class="wp-block-paragraph">Largest Contentful Paint is when the largest visible element finishes rendering. Good is under 2.5 seconds. On WordPress it is almost always the hero image or the first image in post content, and the causes are boringly consistent.</p>



<h3 class="wp-block-heading">The lazy-loaded hero</h3>



<p class="wp-block-paragraph">Still the most common LCP problem on WordPress sites, years after core started handling it. Core skips lazy loading on the first image in content and adds <code>fetchpriority="high"</code> to the likely LCP candidate, but the heuristic only works on images core knows about. A hero rendered by a page builder, a theme header, or a slider often sits outside that, and some optimisation plugins re-add <code>loading="lazy"</code> to everything regardless.</p>



<pre class="wp-block-code"><code># Look at what the server actually sends. If your hero image has
# loading="lazy" on it, you have found your LCP problem.
curl -s https://example.com/ | grep -o '&lt;img[^&gt;]*&gt;' | head -5</code></pre>



<p class="wp-block-paragraph">For a theme-rendered hero, be explicit rather than hoping a heuristic catches it:</p>



<pre class="wp-block-code"><code>&lt;!-- No loading="lazy". width and height reserve space, which
     also protects CLS. fetchpriority tells the browser this
     is the one image worth fetching before everything else. --&gt;
&lt;img src="/hero.webp"
     width="1600" height="900"
     fetchpriority="high"
     decoding="async"
     alt=""&gt;</code></pre>



<h3 class="wp-block-heading">Slow server response</h3>



<p class="wp-block-paragraph">LCP cannot be faster than time to first byte. On shared hosting with no page cache, an uncached WordPress response can eat most of your budget before a single byte of image is requested. Full-page caching is the single highest-yield change on most WordPress sites, and a CDN in front of it removes the geographic distance as well.</p>



<p class="wp-block-paragraph">If TTFB is still poor with caching on, the cache is being bypassed. Cookies from analytics, a cart, or a logged-in session will do it. Check the cache-status header on a real request rather than trusting the plugin&#8217;s dashboard.</p>



<h3 class="wp-block-heading">Render-blocking CSS</h3>



<p class="wp-block-paragraph">The browser will not paint until it has the stylesheets. A theme loading four separate CSS files plus a font stylesheet plus an icon library delays every LCP candidate on the page. Self-host fonts with <code>font-display: swap</code>, drop icon libraries you use six glyphs from, and be sceptical of &#8220;combine CSS&#8221; plugin features, which on HTTP/2 often make things worse rather than better.</p>



<h3 class="wp-block-heading">Speculative loading, which is free</h3>



<p class="wp-block-paragraph">Worth knowing because most WordPress performance guides predate it. Since WordPress 6.8, core ships speculative loading using the Speculation Rules API: the browser starts fetching a link&#8217;s destination when the visitor interacts with it, so the next page is partly or entirely loaded before they arrive. It is on by default for logged-out visitors, using prefetch with &#8220;conservative&#8221; eagerness, and it is a progressive enhancement with no downside for browsers that do not support it.</p>



<p class="wp-block-paragraph">You can push it further, at a cost:</p>



<pre class="wp-block-code"><code>add_filter( 'wp_speculation_rules_configuration', function ( $config ) {
    // Prerender actually renders the next page, including running
    // its JavaScript. Much faster, and analytics or anything with
    // side effects may fire for a visit that never happens.
    return array(
        'mode'      =&gt; 'prerender',
        'eagerness' =&gt; 'moderate',
    );
} );</code></pre>



<p class="wp-block-paragraph">Test that carefully on anything transactional. Core also honours <code>no-prefetch</code> and <code>no-prerender</code> CSS classes on individual links, which is the simplest way to keep carts, checkouts and logout links out of it.</p>



<h2 class="wp-block-heading">INP: the hard one, and it is your JavaScript</h2>



<p class="wp-block-paragraph">Interaction to Next Paint measures the time from a user interacting to the browser painting the response, across every interaction on the page, and reports roughly the worst one. Good is under 200 milliseconds; above 500 is poor.</p>



<p class="wp-block-paragraph">It replaced First Input Delay in March 2024, and the change matters more than a metric swap sounds. FID measured only the delay before processing the first interaction. INP measures the whole round trip, every time. Plenty of sites that comfortably passed FID fail INP, because the thing that was always sluggish is now the thing being graded.</p>



<p class="wp-block-paragraph">Since no lab tool reports it, use Total Blocking Time in Lighthouse as a proxy, and Chrome DevTools&#8217; Performance panel to find which handler is blocking. On WordPress the usual suspects:</p>



<ul class="wp-block-list">
<li><strong>Plugin JavaScript loading site-wide.</strong> A form builder, a slider and a popup tool each shipping their bundle on every page, including pages that use none of them.</li>
<li><strong>Third-party tags.</strong> Tag managers, chat widgets, heatmaps, ad scripts. These run on the main thread and are usually the largest single contributor.</li>
<li><strong>Long tasks in event handlers.</strong> Anything doing real work synchronously in response to a click.</li>
<li><strong>jQuery-era code</strong> attaching handlers to hundreds of elements on a listing page.</li>
</ul>



<p class="wp-block-paragraph">The fixes, in order of yield. First, stop shipping JavaScript to pages that do not use it. Conditionally dequeue plugin assets rather than accepting that a contact form script belongs on every article. This alone often solves INP.</p>



<p class="wp-block-paragraph">Second, audit the third-party tags. Every one is somebody&#8217;s request from two years ago, and at least one of them is for a tool nobody uses any more. Removing a tag is the cheapest performance work available.</p>



<p class="wp-block-paragraph">Third, break up long tasks so the browser can paint between chunks:</p>



<pre class="wp-block-code"><code>// Yielding lets the browser respond to the click that started this,
// instead of finishing all the work first. INP measures exactly
// that gap, so yielding moves the metric even though total work
// stays the same.
async function processAll(items) {
  for (const item of items) {
    doWork(item);
    await new Promise(resolve =&gt; setTimeout(resolve, 0));
  }
}</code></pre>



<p class="wp-block-paragraph">Newer Chrome versions offer <code>scheduler.yield()</code>, which does the same thing without losing your place in the task queue. Feature-detect it and fall back to the above.</p>



<p class="wp-block-paragraph">One honest warning: &#8220;delay JavaScript until user interaction&#8221; features in optimisation plugins look like an INP fix and frequently are not. Delaying scripts until the first interaction means the first interaction now waits for all of them to load and execute, which is the worst possible moment. It improves lab scores and can make INP worse.</p>



<h2 class="wp-block-heading">CLS: usually an afternoon</h2>



<p class="wp-block-paragraph">Cumulative Layout Shift measures unexpected movement of visible content. Good is under 0.1, and it is the metric most likely to be fixable in one sitting.</p>



<ul class="wp-block-list">
<li><strong>Images without dimensions.</strong> Set <code>width</code> and <code>height</code> so the browser reserves space before the file arrives. WordPress does this for media library images; hand-written theme markup often does not.</li>
<li><strong>Ads and embeds.</strong> Reserve a fixed-height container. An ad slot that collapses to nothing and then expands is a large shift on every page view.</li>
<li><strong>Fonts.</strong> A web font swapping in at a different metric reflows everything. <code>font-display: swap</code> plus <code>size-adjust</code> on the fallback, or a system font stack, both work.</li>
<li><strong>Cookie banners and notices injected by JavaScript</strong> that push the page down rather than overlaying it.</li>
<li><strong>Lazy-loaded content above the fold</strong> that appears after paint.</li>
</ul>



<p class="wp-block-paragraph">Chrome DevTools&#8217; Performance panel highlights layout shifts with the element that moved, which turns this from detective work into a checklist.</p>



<h2 class="wp-block-heading">The order I&#8217;d work in</h2>



<ol class="wp-block-list">
<li><strong>Anything in the &#8220;poor&#8221; band</strong>, whichever metric it is. Poor hurts more than two mediocre metrics.</li>
<li><strong>Caching and TTFB</strong>, because it is one change and it moves LCP for every page at once.</li>
<li><strong>The LCP image</strong> on your highest-traffic template.</li>
<li><strong>JavaScript you can delete.</strong> Unused plugin assets and dead third-party tags, for INP.</li>
<li><strong>CLS</strong>, which is quick and visible to users even where the number already passes.</li>
<li><strong>Everything else</strong>, measured one change at a time.</li>
</ol>



<p class="wp-block-paragraph">What I would skip: chasing a green Lighthouse score once field data passes, stacking a second optimisation plugin on top of the first, and micro-optimising a metric that is already comfortably good. There is no bonus for 1.1 seconds when 2.5 is the bar.</p>



<h2 class="wp-block-heading">When the numbers don&#8217;t move</h2>



<p class="wp-block-paragraph"><strong>It has been two weeks.</strong> That is normal. The window is 28 days and rolling, so give it a full month before concluding anything.</p>



<p class="wp-block-paragraph"><strong>Lab improved, field did not.</strong> You optimised something the lab measures and users do not experience, or the problem is INP, which the lab never measured. Go back to the field data section.</p>



<p class="wp-block-paragraph"><strong>Desktop passes, mobile fails.</strong> Expected, and mobile is what is graded for most sites. Test with CPU throttling on, because the gap is usually processing rather than network.</p>



<p class="wp-block-paragraph"><strong>One URL is fine but the group fails.</strong> Origin-level fallback, or a different template in the same group is dragging it. Check what else Search Console has bundled together.</p>



<p class="wp-block-paragraph"><strong>It regressed after a plugin update.</strong> Very common, and the reason to record your field numbers before and after any significant change rather than relying on memory.</p>



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



<ul class="wp-block-list">
<li>Treating the PageSpeed Insights score as the thing being measured.</li>
<li>Optimising INP with a lab tool that cannot measure INP.</li>
<li>Testing from a fast laptop on fibre and concluding the site is fine.</li>
<li>Lazy-loading the hero image, or letting a plugin re-add it.</li>
<li>Running two optimisation plugins that both minify and both defer.</li>
<li>Enabling &#8220;delay JavaScript until interaction&#8221; and expecting INP to improve.</li>
<li>Judging a fix after a week.</li>
<li>Loading every plugin&#8217;s JavaScript on every page.</li>
<li>Adding a CDN while the origin has no page cache.</li>
<li>Fixing the homepage when the failing template is the blog archive.</li>
</ul>



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



<ul class="wp-block-list">
<li>Decide what is broken from field data; work out why from lab data.</li>
<li>Fix by template, not by URL, since one template covers thousands of pages.</li>
<li>Full-page caching first, then a CDN, then image work.</li>
<li>Explicit <code>width</code> and <code>height</code> on every image in theme markup.</li>
<li><code>fetchpriority="high"</code> on the LCP image and lazy loading on nothing above the fold.</li>
<li>Dequeue plugin assets on pages that do not use them.</li>
<li>Review third-party tags on a schedule and delete the ones nobody owns.</li>
<li>Self-host fonts with <code>font-display: swap</code>.</li>
<li>Real user monitoring so you are not waiting a month for feedback.</li>
<li>Record field numbers before and after every significant change.</li>
</ul>



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



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



<h3 class="wp-block-heading">Why is my PageSpeed score 95 but Search Console says I&#8217;m failing?</h3>



<p class="wp-block-paragraph">They measure different things. The score is a simulated load on your connection; the assessment is real Chrome users at the 75th percentile over 28 days. Read the field data section at the top of PageSpeed Insights, not the score.</p>



<h3 class="wp-block-heading">How long before a fix shows up?</h3>



<p class="wp-block-paragraph">The data window is 28 days and rolls daily, so improvement appears gradually and takes about a month to fully reflect. Real user monitoring gives you same-day feedback if you cannot wait.</p>



<h3 class="wp-block-heading">Will a caching plugin fix Core Web Vitals?</h3>



<p class="wp-block-paragraph">It will help LCP substantially, because TTFB is a floor under it. It does very little for INP, which is about JavaScript execution, and nothing for CLS. Caching is a strong first move and not a complete answer.</p>



<h3 class="wp-block-heading">What happened to FID?</h3>



<p class="wp-block-paragraph">INP replaced it in March 2024 and FID is retired. Any guide still listing it predates the change, which is a useful signal about the rest of its advice.</p>



<h3 class="wp-block-heading">Do I need to switch themes?</h3>



<p class="wp-block-paragraph">Sometimes, and it is a bigger project than it looks. A heavy multipurpose theme loading a page builder, a slider library and an icon set on every request is fighting you. Measure first, because a theme swap on a site with hundreds of pages is rarely the cheapest route to passing.</p>



<h3 class="wp-block-heading">Does passing actually help rankings?</h3>



<p class="wp-block-paragraph">Core Web Vitals are a ranking signal among many, and content relevance dominates. The reliable win is on the user side: faster pages convert better and get abandoned less. Treat ranking as a bonus rather than the business case.</p>



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



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



<p class="wp-block-paragraph">You cannot fix what you are not measuring, and the tool most people measure with cannot see the metric most people are failing. Lighthouse is a debugger. The Chrome User Experience Report is the exam.</p>



<p class="wp-block-paragraph">So open Search Console before you open PageSpeed Insights, find out which of the three is actually red, and fix that one. Then wait a month before deciding whether it worked, because the measurement takes longer than the work.</p>



<h2 class="wp-block-heading">Site failing and not sure why?</h2>



<p class="wp-block-paragraph">Most of the WordPress sites I get sent have already had a fortnight thrown at the wrong metric. Work I take on:</p>



<ul class="wp-block-list">
<li>Diagnosing which Core Web Vital is actually failing, for which templates, from field data rather than a score.</li>
<li>LCP work: caching and TTFB, image priority and formats, render-blocking CSS, CDN configuration.</li>
<li>INP work, which is mostly JavaScript archaeology: dequeuing plugin assets by page, auditing third-party tags, breaking up long tasks.</li>
<li>CLS fixes across templates, including ads, embeds, fonts and consent banners.</li>
<li>Setting up real user monitoring so you get feedback in a day instead of a month.</li>
<li>Untangling sites running two or three overlapping optimisation plugins.</li>
</ul>



<p class="wp-block-paragraph">Send me your Search Console Core Web Vitals report and a URL from a failing template, and I will tell you what is actually wrong.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/">A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>CDN Setup Without Breaking Dynamic Content</title>
		<link>https://john-nessime.com/blog/devops/cdn-setup-dynamic-content/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 22:51:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Edge Computing]]></category>
		<category><![CDATA[HTTP Headers]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=49</guid>

					<description><![CDATA[<p>Put a CDN in front of a dynamic site carelessly and it will cache one user's personalised page and serve it to everyone — invisibly, because your logged-in browser looks fine. Here is how to classify routes, set cache headers that hold, and run the two curl commands that catch the leak.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/cdn-setup-dynamic-content/">CDN Setup Without Breaking Dynamic Content</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Somebody puts a CDN in front of the site on a Friday. The homepage gets faster, the bill goes down, everyone is pleased. On Monday a customer emails to say they logged in and saw someone else&#8217;s name in the header.</p>



<p class="wp-block-paragraph">That is the failure mode worth taking seriously. Not slowness, not downtime — the CDN cached a page that was personalised for one user and served it to everybody else. And you will not notice, because your browser holds a session and looks fine. The people seeing the broken version are the ones who never contact you.</p>



<p class="wp-block-paragraph">Getting CDN setup right for a dynamic site is not about finding the right toggle in a dashboard. It is about deciding, per route, what is public and what is personal, and then making the origin say so clearly enough that no edge node has to guess.</p>



<h2 class="wp-block-heading">Sort your routes before touching any config</h2>



<p class="wp-block-paragraph">Every URL on your site falls into one of three buckets. Write the list out — actually write it, this takes ten minutes and prevents the incident above.</p>



<ul class="wp-block-list">
<li><strong>Static assets.</strong> CSS, JS, images, fonts. Identical for everyone, content-addressed if your build is sensible. Cache these hard, for a year.</li>

<li><strong>Public dynamic.</strong> Generated by the application but identical for every anonymous visitor. Blog posts, product pages, category listings. This is where the real performance win lives, and where most people leave everything uncached out of nervousness.</li>

<li><strong>Private dynamic.</strong> Anything personalised or authenticated. Carts, dashboards, checkout, admin, most API responses. Never cached at a shared cache, no exceptions.</li>
</ul>



<p class="wp-block-paragraph">The middle bucket is the whole game. If you cache only the first bucket you have built an asset CDN and left the expensive work untouched. If you accidentally cache the third, you have a data leak.</p>



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



<h2 class="wp-block-heading">The header that separates the buckets</h2>



<p class="wp-block-paragraph">The single most useful thing to understand is the difference between <code>private</code> and <code>no-store</code>, because people use them interchangeably and they mean very different things.</p>



<pre class="wp-block-code"><code># Static assets with a content hash in the filename
Cache-Control: public, max-age=31536000, immutable

# Public dynamic: edge caches it, browser revalidates
Cache-Control: public, s-maxage=300, max-age=0, must-revalidate

# Personalised: browser may cache, shared caches must not
Cache-Control: private, no-store

# Genuinely sensitive: nobody stores this anywhere
Cache-Control: no-store</code></pre>



<p class="wp-block-paragraph"><code>private</code> means &#8220;a browser cache may keep this, a shared cache may not&#8221;. <code>no-store</code> means &#8220;do not write this to disk anywhere&#8221;. For an authenticated dashboard you want both. For a bank statement you want <code>no-store</code> alone.</p>



<p class="wp-block-paragraph">The other key pair is <code>s-maxage</code> versus <code>max-age</code>. <code>s-maxage</code> applies only to shared caches — the CDN — and overrides <code>max-age</code> there. That lets you cache a product page at the edge for five minutes while telling browsers to revalidate every time, so a price change propagates in five minutes rather than living in someone&#8217;s browser for an hour.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Set caching headers at the origin, per route, and let the CDN follow them. Configuring cache rules only in the CDN dashboard means the policy lives somewhere your application code cannot see and your staging environment does not have.</p>
</blockquote>



<h2 class="wp-block-heading">Vary, and how it quietly ruins your hit rate</h2>



<p class="wp-block-paragraph"><code>Vary</code> tells caches that the response depends on a request header, so a separate copy is stored per distinct value. Used precisely it is essential. Used carelessly it destroys your cache.</p>



<pre class="wp-block-code"><code># Fine. A handful of encodings, a handful of variants.
Vary: Accept-Encoding

# Also fine if you genuinely serve different content per language
Vary: Accept-Encoding, Accept-Language</code></pre>



<p class="wp-block-paragraph">Now the one to avoid:</p>



<pre class="wp-block-code"><code># Effectively disables caching. Every browser build is a distinct
# User-Agent string, so every visitor gets their own cache entry.
Vary: User-Agent</code></pre>



<p class="wp-block-paragraph">If you need device-specific responses, most CDNs expose a normalised device-type value with two or three possible values. Use that rather than the raw header.</p>



<p class="wp-block-paragraph"><code>Vary: Cookie</code> deserves its own warning. It looks like a safe way to separate logged-in from anonymous traffic, and it is not — every visitor carries slightly different cookies from analytics and consent tools, so you get a near-unique cache key per person. The correct approach is to make the presence of a session cookie bypass the cache entirely, rather than varying on it.</p>



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



<h2 class="wp-block-heading">Cookies are where sites actually break</h2>



<p class="wp-block-paragraph">Two rules, and between them they prevent most CDN incidents.</p>



<h3 class="wp-block-heading">Rule one: a session cookie means bypass</h3>



<p class="wp-block-paragraph">If a request arrives carrying your session cookie, it should go to origin uncached. Most CDNs let you express this as a cache rule; in nginx, if you are running your own edge, it is a variable:</p>



<pre class="wp-block-code"><code>map $http_cookie $bypass_cache {
    default                 0;
    "~*wordpress_logged_in" 1;
    "~*woocommerce_items"   1;
    "~*comment_author"      1;
    "~*PHPSESSID"           1;
}

location / {
    proxy_cache            edge_cache;
    proxy_cache_bypass     $bypass_cache;   # do not serve from cache
    proxy_no_cache         $bypass_cache;   # do not write to cache
    proxy_cache_valid      200 301 302 5m;
    proxy_cache_use_stale  error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_lock       on;

    add_header X-Cache-Status $upstream_cache_status always;
    proxy_pass http://origin;
}</code></pre>



<p class="wp-block-paragraph">You need <em>both</em> directives. <code>proxy_cache_bypass</code> stops a logged-in user being served a cached anonymous page. <code>proxy_no_cache</code> stops their personalised response being written into the cache for everyone else. Setting only the first is a common and dangerous mistake — the read path is protected while the write path is wide open.</p>



<p class="wp-block-paragraph">That <code>X-Cache-Status</code> header is not decoration. It is how you verify any of this actually works.</p>



<h3 class="wp-block-heading">Rule two: strip cookies you do not need</h3>



<p class="wp-block-paragraph">Analytics and consent tools set cookies on every visitor. If your CDN treats any cookie as a bypass signal, your anonymous traffic stops being cacheable the moment you add a tag manager. Configure the CDN to ignore everything except the specific cookies that matter, and be explicit about which those are.</p>



<h2 class="wp-block-heading">Query strings, and the cache-key explosion</h2>



<p class="wp-block-paragraph">By default many CDNs include the full query string in the cache key. Then a marketing campaign starts appending <code>utm_source</code>, <code>utm_medium</code>, <code>fbclid</code> and friends, and one page becomes thousands of cache entries that each miss exactly once.</p>



<p class="wp-block-paragraph">Decide deliberately which parameters affect the response:</p>



<ul class="wp-block-list">
<li><strong>Include</strong> parameters that change the content — <code>?page=2</code>, <code>?sort=price</code>, <code>?category=boots</code>.</li>

<li><strong>Ignore</strong> tracking parameters entirely. They never change what the server renders.</li>

<li><strong>Normalise order</strong> where the CDN supports it, so <code>?a=1&amp;b=2</code> and <code>?b=2&amp;a=1</code> are one entry rather than two.</li>
</ul>



<p class="wp-block-paragraph">An allowlist is safer than a blocklist here. New tracking parameters appear constantly; the set of parameters your application actually reads changes rarely.</p>



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



<h2 class="wp-block-heading">Keeping dynamic pages cached without going stale</h2>



<p class="wp-block-paragraph">The usual objection to caching public dynamic pages is that content changes. Two mechanisms solve this, and together they let you cache aggressively without serving stale content for long.</p>



<h3 class="wp-block-heading">Stale-while-revalidate</h3>



<pre class="wp-block-code"><code>Cache-Control: public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400</code></pre>



<p class="wp-block-paragraph">Read that as three separate promises. Fresh for sixty seconds. For the following ten minutes, serve the stale copy immediately while fetching a fresh one in the background — so nobody waits. And if the origin is throwing errors, keep serving the stale copy for a day rather than showing a 502.</p>



<p class="wp-block-paragraph">That last directive turns your CDN into a partial outage shield. It is one of the highest-value headers on this page and it costs nothing.</p>



<h3 class="wp-block-heading">Purge on change</h3>



<p class="wp-block-paragraph">Time-based expiry is a blunt instrument. If your application knows when a product price changed, tell the CDN immediately rather than waiting for a TTL.</p>



<p class="wp-block-paragraph">Tag-based purging is the version worth building toward — tag responses by the entities they contain, then purge by tag when an entity changes:</p>



<pre class="wp-block-code"><code># Origin response for a product page
Cache-Tag: product-1234, category-boots, layout-main</code></pre>



<p class="wp-block-paragraph">Update product 1234 and you purge one tag, invalidating the product page, every category listing it appears on, and the homepage if it is featured — without knowing any of those URLs in advance. Header name and support vary by provider, so check what yours calls it.</p>



<h2 class="wp-block-heading">Verifying it before you point DNS</h2>



<p class="wp-block-paragraph">Test with curl, not a browser. Your browser has cookies, a warm local cache and an opinion; curl tells you the truth.</p>



<pre class="wp-block-code"><code># What headers is the origin actually sending?
curl -sI https://example.com/products/boots | grep -i 'cache-control|vary|set-cookie'

# Twice in a row: second request should show a HIT
curl -sI https://example.com/products/boots | grep -i 'x-cache|age'
curl -sI https://example.com/products/boots | grep -i 'x-cache|age'</code></pre>



<p class="wp-block-paragraph">Now the test that matters most. Request an authenticated page with a real session cookie and confirm it never returns a cache hit:</p>



<pre class="wp-block-code"><code>curl -sI https://example.com/account 
  -H "Cookie: session=&lt;a-real-session-value&gt;" 
  | grep -i 'x-cache|cache-control'</code></pre>



<p class="wp-block-paragraph">You want <code>BYPASS</code> or <code>MISS</code>, and <code>Cache-Control: private</code>. If that ever shows <code>HIT</code>, stop and fix it before going further.</p>



<p class="wp-block-paragraph">The leak test, which is the one people skip: fetch a personalised page with a session, then fetch the same URL with no cookies at all and confirm you get the anonymous version.</p>



<pre class="wp-block-code"><code># With a session
curl -s https://example.com/account -H "Cookie: session=&lt;value&gt;" | grep -i 'welcome back'

# Immediately after, with nothing. Should NOT show the personalised content.
curl -s https://example.com/account | grep -i 'welcome back'</code></pre>



<p class="wp-block-paragraph">If the second command finds personalised content, the CDN cached a private response. That is the Monday-morning email, and you have just caught it before your customers did.</p>



<h3 class="wp-block-heading">Test the edge before DNS points at it</h3>



<pre class="wp-block-code"><code>curl -sI https://example.com/ --resolve example.com:443:&lt;edge-ip&gt;</code></pre>



<p class="wp-block-paragraph">That sends a real request to a specific edge node with the correct hostname and TLS SNI, without changing DNS. It is how you find out whether the whole thing works before any user does.</p>



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



<h2 class="wp-block-heading">Things that break in ways nobody expects</h2>



<h3 class="wp-block-heading">Client IPs all become the CDN&#8217;s</h3>



<p class="wp-block-paragraph">Your logs fill with edge node addresses, rate limiting starts throttling everyone at once, and geolocation breaks. The real address arrives in a forwarded header, and your origin has to be configured to trust it — but only from the CDN&#8217;s address ranges. Trusting <code>X-Forwarded-For</code> from anywhere lets any visitor claim any IP they like, which turns a logging annoyance into an authentication bypass.</p>



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



<p class="wp-block-paragraph">The CDN talks to your origin over HTTP, the origin sees a non-HTTPS request and redirects to HTTPS, which comes back through the CDN, forever. Either terminate TLS at the origin too, or have the origin trust <code>X-Forwarded-Proto</code> when deciding whether to redirect.</p>



<h3 class="wp-block-heading">POST responses getting cached</h3>



<p class="wp-block-paragraph">Shouldn&#8217;t happen by default, but a badly written cache rule can do it, and the result is one user&#8217;s form submission response served to the next person. Only cache <code>GET</code> and <code>HEAD</code>. Ever.</p>



<h3 class="wp-block-heading">Websockets and streaming</h3>



<p class="wp-block-paragraph">Websocket upgrades and server-sent events need to pass through untouched. Buffering breaks streaming responses silently — the connection works, the data just arrives in one lump at the end, which looks like an application bug rather than a proxy setting.</p>



<h3 class="wp-block-heading">Set-Cookie on a cacheable response</h3>



<p class="wp-block-paragraph">If a public page returns <code>Set-Cookie</code> and gets cached, every subsequent visitor receives that cookie — potentially somebody&#8217;s session identifier. Most CDNs refuse to cache responses carrying <code>Set-Cookie</code>, but not all, and not always. Check your origin is not setting cookies on pages you intend to cache.</p>



<h2 class="wp-block-heading">Rolling it out without a bad week</h2>



<ol class="wp-block-list">
<li><strong>Write down the route list.</strong> Three buckets, every path accounted for.</li>

<li><strong>Set headers at the origin first,</strong> with the CDN not yet in front. Verify with curl.</li>

<li><strong>Start with everything bypassed</strong> except static assets. A CDN that caches nothing still terminates TLS closer to users and is not a failure.</li>

<li><strong>Add public dynamic routes one at a time,</strong> with short TTLs. Sixty seconds, then extend once you trust it.</li>

<li><strong>Run the leak test</strong> after every rule change, not just at the end.</li>

<li><strong>Watch origin request volume.</strong> It should drop noticeably. If it does not, something is bypassing that you think is caching.</li>

<li><strong>Keep a purge command to hand</strong> and know how long a full purge takes before you need it.</li>
</ol>



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



<ul class="wp-block-list">
<li>Testing only in a logged-in browser, where cached-private-content bugs are invisible.</li>

<li>Setting <code>proxy_cache_bypass</code> without <code>proxy_no_cache</code>, protecting reads but not writes.</li>

<li><code>Vary: Cookie</code> or <code>Vary: User-Agent</code>, which shatters the cache into near-unique entries.</li>

<li>Leaving tracking parameters in the cache key.</li>

<li>Configuring caching in the CDN dashboard only, so the policy is invisible to your code and absent from staging.</li>

<li>Trusting forwarded IP headers from any source rather than only the CDN&#8217;s ranges.</li>

<li>Using <code>private</code> where you needed <code>no-store</code>, or the reverse.</li>

<li>Caching everything on day one and finding the problems in production.</li>
</ul>



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



<ul class="wp-block-list">
<li>Default to no caching, then allow specific routes. Opt-in beats opt-out when the failure mode is a data leak.</li>

<li>Own the policy at the origin so it travels with your code and exists in staging.</li>

<li>Use <code>s-maxage</code> for the edge and <code>max-age</code> for browsers. They are different problems.</li>

<li>Always set <code>stale-if-error</code>. It is free outage insurance.</li>

<li>Expose a cache status header and monitor hit ratio per route, not site-wide.</li>

<li>Build purge-on-publish into the application rather than relying on TTLs alone.</li>

<li>Make the leak test part of CI, so a future config change cannot reintroduce it quietly.</li>
</ul>



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



<h3 class="wp-block-heading">Can a CDN cache dynamic pages at all?</h3>



<p class="wp-block-paragraph">Yes, and that is where most of the benefit is. Dynamic does not mean personalised. A blog post rendered from a database is identical for every anonymous visitor and can be cached at the edge for minutes. Only genuinely per-user content must bypass.</p>



<h3 class="wp-block-heading">What is the difference between private and no-store?</h3>



<p class="wp-block-paragraph"><code>private</code> permits browser caching but forbids shared caches. <code>no-store</code> forbids writing the response to any cache at all. Use both for authenticated pages; use <code>no-store</code> alone for genuinely sensitive responses.</p>



<h3 class="wp-block-heading">Why did my hit ratio collapse after adding a cookie banner?</h3>



<p class="wp-block-paragraph">Because the consent tool sets a cookie on every visitor and your CDN treats any cookie as a bypass signal, or you are varying on <code>Cookie</code>. Configure the CDN to ignore all cookies except the specific ones that indicate a session.</p>



<h3 class="wp-block-heading">How do I verify private pages are not being cached?</h3>



<p class="wp-block-paragraph">Request the page with a real session cookie and confirm the cache status shows a bypass or miss, never a hit. Then request the same URL with no cookies and confirm the personalised content is absent. Do this after every cache rule change.</p>



<h3 class="wp-block-heading">Should I cache API responses?</h3>



<p class="wp-block-paragraph">Public read-only endpoints, yes — with short TTLs and a cache key that includes whatever parameters affect the response. Anything requiring an Authorization header should be treated as private by default.</p>



<h3 class="wp-block-heading">Do I still need caching at the origin?</h3>



<p class="wp-block-paragraph">Yes. The CDN protects you from repeat requests for the same URL; it does nothing for the first request, for uncacheable routes, or during a purge. Origin-level caching handles those.</p>



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



<p class="wp-block-paragraph">A CDN in front of a dynamic site is a classification problem wearing a performance costume. Sort your routes into public, private and static; express that at the origin with <code>Cache-Control</code>; make session cookies a hard bypass on both the read and write path; and keep tracking parameters out of the cache key.</p>



<p class="wp-block-paragraph">Then test the way an anonymous visitor arrives, not the way you browse your own site. Two curl commands — one with a session, one without — catch the failure that matters, and they take about ten seconds. Run them every time you change a cache rule and the Monday email never arrives.</p>



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



<h2 class="wp-block-heading">Want a CDN set up without the surprises?</h2>



<p class="wp-block-paragraph">I work with teams on edge caching for dynamic sites — route classification, cache headers that survive a redesign, purge-on-publish wired into the application, and the monitoring that tells you when hit ratio quietly drops.</p>



<p class="wp-block-paragraph">If you would rather have this done carefully the first time, you can hire me on Upwork.</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/cdn-setup-dynamic-content/">CDN Setup Without Breaking Dynamic Content</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
