<?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>Nginx | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/nginx/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/nginx/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Tue, 13 Oct 2026 06:06:40 +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>Nginx | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/nginx/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Your WordPress 403 Isn&#8217;t the Rule You Think: Tuning ModSecurity False Positives</title>
		<link>https://john-nessime.com/blog/web-security/modsecurity-false-positives-wordpress/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 13 Oct 2026 06:06:40 +0000</pubDate>
				<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Anomaly Scoring]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Audit Logging]]></category>
		<category><![CDATA[Coraza]]></category>
		<category><![CDATA[False Positives]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[ModSecurity]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OWASP CRS]]></category>
		<category><![CDATA[Rule Exclusions]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[Web Application Firewall]]></category>
		<category><![CDATA[WordPress Security]]></category>
		<category><![CDATA[wp-admin]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=434</guid>

					<description><![CDATA[<p>A 403 from ModSecurity almost never names the rule you need to change. Under anomaly scoring, detection rules only add to a score and a separate blocking rule returns the error. This guide covers reading the audit log backwards, choosing between the four exclusion mechanisms, the WordPress failure families that cause most false positives, and why raising the anomaly threshold quietly destroys your coverage.</p>
<p>The post <a href="https://john-nessime.com/blog/web-security/modsecurity-false-positives-wordpress/">Your WordPress 403 Isn&#8217;t the Rule You Think: Tuning ModSecurity False Positives</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket almost always reads the same way. Someone spent twenty minutes writing a post, hit Update, and got a bare <code>403 Forbidden</code>. Nothing in the WordPress debug log. Nothing in the PHP error log. The plugin they blame was not updated. The post they wrote yesterday saved fine.</p>



<p class="wp-block-paragraph">Then you open the ModSecurity audit log and find eleven rule matches on a single request, none of them obviously the culprit.</p>



<p class="wp-block-paragraph">Dealing with ModSecurity false positives on WordPress is not hard once you understand how the OWASP Core Rule Set decides to block. It is very hard if you go looking for &#8220;the rule that blocked me,&#8221; because under the default configuration that rule does not exist. This post covers reading the log, choosing the right exclusion mechanism, the WordPress failure families that generate most of the noise, and the tuning knobs that quietly gut your coverage.</p>



<h2 class="wp-block-heading">The invisible part: no single rule blocked the request</h2>



<p class="wp-block-paragraph">The Core Rule Set runs in anomaly scoring mode by default. Individual detection rules do not deny anything. They match, they log, and they add to a running score with <code>setvar</code>. At the end of the request phase, a separate blocking evaluation rule compares the accumulated score against a threshold and returns the 403.</p>



<p class="wp-block-paragraph">Scores come from rule severity. A CRITICAL match adds 5, ERROR adds 4, WARNING adds 3, NOTICE adds 2. The inbound threshold ships at 5 and the outbound threshold at 4. So one critical match is enough on its own, and so are two notices plus a warning.</p>



<p class="wp-block-paragraph">That is the failure mode nobody warns you about. The log line that produced your 403 lives in <code>REQUEST-949-BLOCKING-EVALUATION.conf</code> and it says the inbound anomaly score was exceeded. It does not name what matched. Take that ID and disable it and you have not fixed a false positive, you have turned off blocking for the whole site while leaving every detection rule running and logging. The site will look fixed. It is not protected.</p>



<p class="wp-block-paragraph">First rule of tuning, then: the blocking rule is a symptom. Read backwards from it.</p>



<h2 class="wp-block-heading">Read the log before you touch a rule</h2>



<p class="wp-block-paragraph">Every alert carries a <code>unique_id</code>. That value ties the error log line to the full audit log record for the same transaction, and it is the only reliable way to see every rule that fired on one request instead of a scattering of lines from different visitors.</p>



<pre class="wp-block-code"><code># Paths vary by build. Check SecAuditLog in modsecurity.conf for yours.
tail -n 200 /var/log/httpd/error_log | grep -i modsecurity

# Then pull the whole transaction using the unique_id from that output
grep -F 'Zm9vYmFyCg' /var/log/httpd/modsec_audit.log</code></pre>



<p class="wp-block-paragraph">From each match, four fields matter and the rest is noise:</p>



<ul class="wp-block-list">
<li><strong>The rule ID</strong>, as <code>[id "941320"]</code>. This is what you exclude.</li>



<li><strong>The matched variable</strong>, at the end of the pattern-match message as something like <code>at ARGS:content</code>. This is what you exclude it <em>from</em>.</li>



<li><strong>The URI</strong>, which tells you how narrowly you can scope the exclusion.</li>



<li><strong>The paranoia level tag</strong>, as <code>[tag "paranoia-level/2"]</code>. If every alert is tagged level 2 or higher, the real fix may be a paranoia level change rather than an exclusion.</li>
</ul>



<p class="wp-block-paragraph">One warning most guides skip: audit logs of false positives contain the payload that triggered them. If a user&#8217;s password happens to look like a shell command, it lands in your log in plaintext. Treat these files as sensitive, rotate them, and redact before pasting anything into a public bug report.</p>



<h2 class="wp-block-heading">Pick the right exclusion mechanism</h2>



<p class="wp-block-paragraph">ModSecurity gives you four ways to write an exclusion, differing on two axes: applied once at startup or evaluated per request, and removing the whole rule or just one variable from it.</p>



<ul class="wp-block-list">
<li><code>SecRuleRemoveById</code> and <code>SecRuleRemoveByTag</code> delete a rule or a whole tagged category at configure time. Blunt, cheap, global.</li>



<li><code>SecRuleUpdateTargetById</code> and <code>SecRuleUpdateTargetByTag</code> strip one variable from a rule at configure time, leaving the rule live for everything else.</li>



<li><code>ctl:ruleRemoveById</code> and <code>ctl:ruleRemoveByTag</code> do the same removal, but only for transactions matching a condition you write.</li>



<li><code>ctl:ruleRemoveTargetById</code> and <code>ctl:ruleRemoveTargetByTag</code> strip one variable, conditionally. This is the surgical option and the one to reach for first.</li>
</ul>



<p class="wp-block-paragraph">Runtime exclusions cost a little, since each is an extra rule evaluated on every request. That overhead is small next to the regex work the rule set already does, and the precision is worth it.</p>



<h3 class="wp-block-heading">Placement decides whether an exclusion works at all</h3>



<p class="wp-block-paragraph">This is where most people lose an afternoon. The two types go in opposite places, for mechanical reasons rather than arbitrary ones.</p>



<ul class="wp-block-list">
<li><strong>Configure-time exclusions go after the CRS include.</strong> They remove rules, and a rule has to exist before you can remove it.</li>



<li><strong>Runtime exclusions go before the CRS include.</strong> They change how rules behave for this transaction, and changing a rule after it has already run does nothing.</li>
</ul>



<p class="wp-block-paragraph">The Core Rule Set ships two files for exactly this, both with an <code>.example</code> suffix you drop before they load. <code>REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf</code> holds runtime exclusions, and <code>RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf</code> holds configure-time ones. Use them. Local files that survive a rule set update are the entire point.</p>



<h2 class="wp-block-heading">Five failure families behind most ModSecurity false positives</h2>



<p class="wp-block-paragraph">Almost everything you will hit on WordPress falls into one of these. Recognizing the family tells you the shape of the fix before you read a single log line.</p>



<h3 class="wp-block-heading">1. Post content that legitimately contains markup</h3>



<p class="wp-block-paragraph">WordPress lets editors put HTML in posts. It maintains its own allowed-tag list, sanitizes server side, and that mechanism has been reviewed hard over the years. The Core Rule Set knows none of this. It sees an <code>&lt;h1&gt;</code> or an inline <code>style</code> attribute in a POST parameter and matches an XSS rule.</p>



<p class="wp-block-paragraph">This produces the &#8220;it saved yesterday&#8221; ticket, because it is content-dependent. Plain paragraphs pass. A pasted table with attributes does not. The fix is a target exclusion scoped to the save endpoint and the specific parameter, never a global rule removal:</p>



<pre class="wp-block-code"><code># Runtime exclusion. Goes in REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
# Replace 941320 and the parameter with what your audit log actually shows.
SecRule REQUEST_URI "@beginsWith /wp-admin/post.php" 
    "id:1000,
    phase:1,
    pass,
    t:none,
    nolog,
    ctl:ruleRemoveTargetById=941320;ARGS:content"</code></pre>



<p class="wp-block-paragraph">Pick your own IDs from a range CRS will never use. Reusing an existing ID is a startup error rather than a silent one, which is the good kind of mistake.</p>



<h3 class="wp-block-heading">2. REST and admin-ajax calls that fail with no visible error</h3>



<p class="wp-block-paragraph">This family is nastier, because there is no error page. The browser hits <code>/wp-json/</code> or <code>/wp-admin/admin-ajax.php</code>, gets a 403, and the JavaScript either swallows it or shows a generic &#8220;updating failed&#8221; toast. Autosave stops. An editor panel stays empty. A form plugin silently drops submissions. Users report this as flakiness rather than a security block, so it sits unfixed for months. When someone says the editor is &#8220;just slow sometimes,&#8221; open the browser network tab and look for 403s before going anywhere near PHP.</p>



<p class="wp-block-paragraph">Scoping differs here. REST routes are structured, so use <code>@rx</code> against <code>REQUEST_FILENAME</code> to hit a route family rather than one path. For <code>admin-ajax.php</code>, the useful discriminator is usually the <code>action</code> parameter, since that one endpoint multiplexes dozens of unrelated plugin handlers.</p>



<h3 class="wp-block-heading">3. Media uploads that break at a size boundary</h3>



<p class="wp-block-paragraph">Multipart uploads have two independent failure paths that look identical from the browser. The first is a genuine rule match on the multipart structure, or on binary content containing a byte sequence some rule dislikes. That shows up with a rule ID and you tune it normally.</p>



<p class="wp-block-paragraph">The second is an engine limit, not a rule. <code>SecRequestBodyLimit</code> caps the total body ModSecurity will buffer, <code>SecRequestBodyNoFilesLimit</code> caps the non-file portion, and <code>SecRequestBodyLimitAction</code> decides whether an oversized body is rejected outright or inspected partially. If uploads fail above a consistent size and no CRS rule ID appears anywhere in the log, stop tuning rules. That is a limit, and the fix is in engine config.</p>



<p class="wp-block-paragraph">Raising them costs memory per concurrent request, and partial inspection leaves part of the body unscanned. Decide which trade you are making rather than bumping the number until the ticket closes.</p>



<h3 class="wp-block-heading">4. Cookies with dynamic names</h3>



<p class="wp-block-paragraph">WordPress authentication cookies carry a hash in the name, so a literal exclusion is impossible. Configure-time directives accept a regular expression in the target if you wrap it in forward slashes:</p>



<pre class="wp-block-code"><code># Configure-time exclusion. Goes in RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf
SecRuleUpdateTargetById 942440 "!REQUEST_COOKIES:/^wordpress_logged_in_.*/"</code></pre>



<p class="wp-block-paragraph">The catch is significant: the <code>ctl</code> action supports no regular expressions at all. If you need a pattern match on a variable name, you are forced into the configure-time form, which applies site-wide rather than to one path. That single constraint drives more design decisions than people expect.</p>



<h3 class="wp-block-heading">5. Chained rules that refuse to be tuned</h3>



<p class="wp-block-paragraph">Some rules are written as a chain of conditions. Target exclusions only apply to the first rule in the chain, so if the variable you want to strip is referenced further down, nothing you write has any effect and you will burn an hour convincing yourself the config file is not loading.</p>



<p class="wp-block-paragraph">When a target exclusion visibly does nothing on a rule you can watch matching, check whether it is chained. If it is, your only option is removing the whole rule under a narrow condition. Accept the wider blast radius, or change the request instead.</p>



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



<h2 class="wp-block-heading">Install the WordPress plugin before writing anything custom</h2>



<p class="wp-block-paragraph">The Core Rule Set maintains a WordPress rule exclusions package. In CRS 3 it was built in and switched on with a variable in <code>crs-setup.conf</code>. In CRS 4 it moved out into a separate plugin published in the CRS plugin registry, and it is not installed by default.</p>



<p class="wp-block-paragraph">That catches people upgrading. Exclusions that used to work vanish, WordPress starts throwing 403s again, and the setup variable they remember is gone from the config file. The reasoning behind the split is sound: fewer rules loaded means less code that can carry a bug, and one of the old exclusion packages did carry a bypass.</p>



<p class="wp-block-paragraph">Plugins load through wildcard includes in a specific order. Get the order wrong and the plugin silently does nothing:</p>



<pre class="wp-block-code"><code>Include /path/to/crs/crs-setup.conf

Include /path/to/crs/plugins/*-config.conf
Include /path/to/crs/plugins/*-before.conf

Include /path/to/crs/rules/*.conf

Include /path/to/crs/plugins/*-after.conf</code></pre>



<p class="wp-block-paragraph">Two limits before you lean on it. The plugin covers vanilla WordPress only, so false positives from third-party plugins, page builders and form plugins are still yours to tune. And the comment field is deliberately left out of the exclusions, because comment forms have a long history as a real attack surface. If your comment form throws false positives, that is a decision the maintainers made on purpose, and overriding it is a genuine risk trade rather than a gap in their package.</p>



<p class="wp-block-paragraph">On a server hosting more than one application, scope the plugin to the WordPress vhost using <code>WebAppID</code> or a Host header condition. WordPress exclusions applied to your unrelated API are a hole you did not mean to open.</p>



<h2 class="wp-block-heading">The knobs that look like a fix and are not</h2>



<p class="wp-block-paragraph">When exclusions get tedious, two shortcuts start looking attractive. Both cost more than they appear to.</p>



<p class="wp-block-paragraph"><strong>Raising the anomaly threshold.</strong> Going from 5 to 20 makes most false positives vanish, and it also means any attack category that scores low can never trigger a block. Detections that only ever produce a single critical match become unblockable. The CRS documentation is direct here: keep the threshold at the default and change the paranoia level instead. Raising it is a temporary tuning aid, not a configuration.</p>



<p class="wp-block-paragraph"><strong>Dropping the paranoia level.</strong> Legitimate, but often applied backwards. Level 1 is designed to produce few false positives. Levels 2 and up add progressively more aggressive rules and progressively more tuning work. If your alerts are all tagged level 2, dropping to level 1 is the correct fix and it is honest about what you gave up.</p>



<p class="wp-block-paragraph">The better tool for moving up is the split between blocking and detection paranoia levels. Set <code>tx.blocking_paranoia_level</code> to what you actually enforce and <code>tx.detection_paranoia_level</code> higher. Rules from the higher level execute and log, but only rules up to the blocking level count toward the score. You get to see what level 2 would have caught, in production, on real traffic, without anyone getting blocked. Then you tune, then you promote. The performance cost lands as soon as you raise the detection level, though, since those rules run either way.</p>



<p class="wp-block-paragraph">Two smaller settings help here. <code>tx.sampling_percentage</code> sends only a fraction of requests into the rule set, a gentler introduction for a busy site than blocking everything on day one. And <code>tx.reporting_level</code> can log requests that <em>would</em> have been blocked at a higher paranoia level, which is exactly the signal you want while climbing.</p>



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



<p class="wp-block-paragraph"><strong>The exclusion has no effect.</strong> Check placement first. A runtime exclusion loaded after the rules, or a configure-time exclusion loaded before them, fails silently. Then check for a chained rule. Then confirm the file is being included at all by breaking it deliberately with a syntax error and seeing whether the server refuses to start.</p>



<p class="wp-block-paragraph"><strong>It works on one site and not another.</strong> Different vhosts load different config. On control panels, per-domain overrides usually sit in a separate file from the global rule set.</p>



<p class="wp-block-paragraph"><strong>It worked and then stopped.</strong> Something updated the rule set. If you edited CRS rule files directly, your edits are gone, which is the whole argument against editing them.</p>



<p class="wp-block-paragraph"><strong>Nothing appears in the log at all.</strong> Confirm the rule engine is on, and that auditing is enabled with enough log parts to include the request body. An engine in detection only mode logs without blocking, which is confusing if you did not know you were in it.</p>



<p class="wp-block-paragraph"><strong>You are on shared hosting and cannot edit any of this.</strong> Some hosts expose a per-domain exclusion box, some let you disable rule IDs, some give you nothing. That is a real ceiling. Providers that hand you root, such as InterServer or Contabo, let you own the rule set and its update cadence, which on a site with genuine tuning needs beats the panel convenience you trade away.</p>



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



<ul class="wp-block-list">
<li>Disabling the blocking evaluation rule because it showed up in the log. It reports the score, it detects nothing, and removing it disables blocking site-wide.</li>



<li>Editing CRS rule files directly. Every update reverts you, and you now maintain a fork by hand.</li>



<li>Excluding by message rather than by ID. Messages carry dynamic data and special characters, so the match is fragile.</li>



<li>Removing an entire attack tag because two rules misfired. <code>SecRuleRemoveByTag attack-sqli</code> is defensible when nothing behind the WAF touches SQL. On WordPress it is not.</li>



<li>Excluding <code>/wp-admin/</code> wholesale, or stacking multiple rule set vendors. Admin is where a compromised session does the most damage, and overlapping rule sets make attribution guesswork.</li>



<li>Leaving known false positives in place. Real attacks get lost in the noise, and the logs accumulate user data you never intended to collect.</li>
</ul>



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



<ul class="wp-block-list">
<li>Start in detection only. Collect real traffic, tune what you find, then enable blocking. Enforcing first is how you learn about broken checkout at two in the morning.</li>



<li>Install the WordPress exclusions plugin before writing custom rules. Do not hand-write what upstream already maintains.</li>



<li>Write the narrowest exclusion that resolves the alert: one rule, one variable, one path. Comment it with the rule ID, the endpoint, and why you judged it safe. Six months later that comment is the only thing between you and a blind revert.</li>



<li>Keep exclusion files in version control and deploy them with the rest of your config. Test rule set updates on staging, since new signatures land regularly.</li>



<li>Ship audit logs somewhere queryable. Grepping a growing text file stops scaling fast, and a platform like Grafana Cloud turns &#8220;which rule fires most&#8221; into a query instead of an afternoon.</li>



<li>If an edge WAF such as Cloudflare sits in front of the origin, decide deliberately which layer owns which rule. Two WAFs scanning the same body doubles your false positive surface for no extra coverage.</li>
</ul>



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



<h3 class="wp-block-heading">How do I find which ModSecurity rule blocked my WordPress request?</h3>



<p class="wp-block-paragraph">Take the <code>unique_id</code> from the 403 entry in the error log and grep the audit log for it. That gives you every rule that fired on the transaction. The blocking evaluation rule is what returned the 403, but the detection rules listed alongside it are what you tune.</p>



<h3 class="wp-block-heading">Should I disable ModSecurity for WordPress instead of tuning it?</h3>



<p class="wp-block-paragraph">It is a defensible choice if the alternative is blocking mode with unresolved false positives, since a WAF everyone has learned to route around is worse than none. But detection only mode gives you the logs with none of the breakage, and that is almost always the better middle ground while you work through the tuning.</p>



<h3 class="wp-block-heading">Is the WordPress rule exclusions plugin enough on its own?</h3>



<p class="wp-block-paragraph">For a stock install on a standard theme, usually. It covers vanilla WordPress and both block-based and classic themes. It does not cover third-party plugins, page builders, or theme frameworks, and those generate most of the false positives on a real site.</p>



<h3 class="wp-block-heading">Why do false positives come back after a rule set update?</h3>



<p class="wp-block-paragraph">Either you edited rule files directly and the update overwrote them, or the update shipped new signatures your existing exclusions do not cover. The first is avoidable by keeping every change in the exclusion files. The second is the cost of running a maintained rule set, and it is why updates belong on staging first.</p>



<h3 class="wp-block-heading">Does ModSecurity slow WordPress down?</h3>



<p class="wp-block-paragraph">It adds per-request work, mostly regular expression evaluation against request data, and the cost scales with paranoia level and body size. The settings that matter are the paranoia level, the request body limits, and whether response body inspection is on. Measure on your own traffic rather than trusting someone else&#8217;s benchmark.</p>



<h3 class="wp-block-heading">Is ModSecurity still maintained?</h3>



<p class="wp-block-paragraph">The engine moved to the OWASP Foundation after Trustwave stepped back from commercial support, and it still receives fixes, though feature work is slower than it once was. The Core Rule Set is a separate project on its own cadence and is actively developed. Coraza is a compatible engine running the same rule language, worth evaluating for new deployments.</p>



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



<p class="wp-block-paragraph">The thing to hold on to about ModSecurity false positives on WordPress is that the rule which returned your 403 is almost never the rule you need to change. Anomaly scoring separates detection from blocking on purpose, and reading the log backwards from the blocking decision to the detection rules that fed it is the whole skill.</p>



<p class="wp-block-paragraph">Everything else follows. Install the maintained WordPress exclusions instead of reinventing them. Write the narrowest exclusion you can, in the file that survives updates. Move the paranoia level, not the threshold.</p>



<h2 class="wp-block-heading">Need help tuning your WAF?</h2>



<p class="wp-block-paragraph">Most of the WAF work I take on is untangling exactly this: a rule set that is technically installed and practically ignored, because nobody had time to work through the false positives. I can help with:</p>



<ul class="wp-block-list">
<li>Triaging your audit logs and mapping every recurring false positive to the rule, variable and endpoint that produced it</li>



<li>Writing scoped exclusion sets for WooCommerce, form plugins, page builders and custom REST routes that upstream packages do not cover</li>



<li>Moving a site off direct rule file edits and onto exclusion files that survive rule set updates</li>



<li>Planning a CRS 3 to CRS 4 migration, including the exclusion package to plugin transition and the paranoia level variable renames</li>



<li>Running a staged rollout from detection only to blocking, with the reporting and sampling settings that make it safe</li>



<li>Shipping ModSecurity audit logs into a queryable log platform so rule tuning becomes a dashboard instead of a grep</li>
</ul>



<p class="wp-block-paragraph">If you have a blocked request you cannot explain, send me the audit log entry and your exclusion file and I will tell you what is firing and why.</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/web-security/modsecurity-false-positives-wordpress/">Your WordPress 403 Isn&#8217;t the Rule You Think: Tuning ModSecurity False Positives</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>DirectAdmin PHP-FPM Pools: Why One WordPress Site Takes Down the Others</title>
		<link>https://john-nessime.com/blog/system-administration/directadmin-php-fpm-pools-wordpress-tuning/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 23 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[CustomBuild]]></category>
		<category><![CDATA[DirectAdmin]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OPcache]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[PHP-FPM]]></category>
		<category><![CDATA[Shared Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[VPS]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=246</guid>

					<description><![CDATA[<p>DirectAdmin gives each user one PHP-FPM pool, not each site. That single design fact explains most of the 502s on a busy shared box. Here is how to find the pool files, size pm.max_children from real memory numbers, set per-site limits that WordPress cannot override, and make the changes survive the next rewrite_confs.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/directadmin-php-fpm-pools-wordpress-tuning/">DirectAdmin PHP-FPM Pools: Why One WordPress Site Takes Down the Others</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket said &#8220;the shop is throwing 502s again.&#8221; The shop was fine. What was not fine was the half-forgotten news site sitting on the same DirectAdmin account, running a broken cron that fired an uncached admin-ajax call every few seconds. It had eaten every worker in the pool, and WooCommerce was queued behind it, waiting for a process that was never going to free up in time.</p>



<p class="wp-block-paragraph">That is the thing about <strong>DirectAdmin PHP-FPM pools</strong> that catches people out. You think you are tuning a site. You are not. You are tuning a user, and every domain that user owns is sharing the same worker budget, the same queue, and the same memory ceiling.</p>



<p class="wp-block-paragraph">This post covers where DirectAdmin actually keeps the pool config, how to size <code>pm.max_children</code> from numbers your server gives you rather than from a blog post&#8217;s magic value, which per-site PHP limits are worth setting and which are theatre, why OPcache does not behave the way you expect on a multi-tenant box, and how to make all of it survive the next CustomBuild run.</p>



<h2 class="wp-block-heading">The design fact that explains most of your 502s</h2>



<p class="wp-block-paragraph">DirectAdmin generates one FPM pool per user account, per PHP version. The pool is named after the Linux user. It listens on a Unix socket named after the user. It runs as that user. Apache or Nginx hands PHP requests to that socket for every domain and subdomain in the account.</p>



<p class="wp-block-paragraph">So if a reseller client has twelve domains under one username, those twelve sites share a single value of <code>pm.max_children</code>. There is no per-domain pool. This is a long-standing feature request rather than an oversight, and it has a real consequence: <strong>the noisiest site in an account sets the availability of every other site in that account.</strong></p>



<p class="wp-block-paragraph">Once you have internalised that, the fix for a lot of &#8220;mystery 502&#8221; tickets stops being a config tweak and starts being an account layout decision. If a site matters commercially, give it its own DirectAdmin user. That is the cleanest isolation the panel offers without going off-piste, and it costs you nothing but a bit of admin tidiness.</p>



<p class="wp-block-paragraph">The alternative, if you have User jails enabled, is DirectAdmin&#8217;s isolated FPM mode. That runs a separate <code>php-fpm</code> master process per jailed user instead of a shared master with many pools:</p>



<pre class="wp-block-code"><code>da config-set isolated_fpm 1
systemctl restart directadmin
da taskq --run 'action=rewrite&amp;value=httpd'</code></pre>



<p class="wp-block-paragraph">It only applies to users who already have Jailed enabled. The trade-off is memory: one master process per user, plus a separate OPcache region for each, adds up quickly. DirectAdmin mitigates that by letting idle per-user FPM instances exit after a period of inactivity, but on a box with sixty accounts you will feel it. I would reach for this on a server hosting a handful of paying clients, not on a general shared box.</p>



<h2 class="wp-block-heading">Where the pool config actually lives</h2>



<p class="wp-block-paragraph">This trips up almost everyone the first time, because there are two files with nearly the same name and only one of them matters for user sites.</p>



<ul class="wp-block-list">

<li><code>/usr/local/php83/etc/php-fpm.conf</code> is the master config for that PHP version. Editing it by hand is how you learn that CustomBuild overwrites it.</li>


<li><code>/usr/local/directadmin/data/users/USERNAME/php/php-fpm83.conf</code> is the generated per-user pool. This is the file that governs a customer&#8217;s sites.</li>


<li><code>/usr/local/directadmin/data/templates/php-fpm.conf</code> is the template those per-user files are rendered from.</li>

</ul>



<p class="wp-block-paragraph">Substitute your PHP version for <code>83</code> throughout. To see what every account is currently running:</p>



<pre class="wp-block-code"><code>ls /usr/local/directadmin/data/users/*/php/php-fpm*.conf

grep -H 'pm.max_children' /usr/local/directadmin/data/users/*/php/php-fpm*.conf</code></pre>



<p class="wp-block-paragraph">The second command is the one I run first on any server I have just inherited. It gives you the entire worker budget of the box on one screen, and it is usually the moment you discover that forty accounts are all sitting on the stock default.</p>



<p class="wp-block-paragraph">The DirectAdmin template ships with <code>pm = ondemand</code> and a low default child count, driven by a <code>MAX_CHILDREN</code> token. Ondemand means no workers exist until a request arrives, and idle workers are reaped after a timeout. On a shared box with many mostly-idle accounts, that is the right default and I would leave it alone. On a server dedicated to one busy WordPress site, the spawn latency on the first request after an idle period is real and switching to <code>dynamic</code> is defensible. Most people who switch to <code>dynamic</code> on a shared server are trading memory they do not have for a latency win they will not notice.</p>



<h2 class="wp-block-heading">Sizing pm.max_children without guessing</h2>



<p class="wp-block-paragraph">Every tuning guide gives you a formula. The formula is fine. The input everyone gets wrong is worker memory, because they use a number from someone else&#8217;s server instead of measuring their own. A lean WordPress install and the same install with a page builder, a security plugin and WooCommerce are not remotely the same workload.</p>



<p class="wp-block-paragraph">Measure it. This averages the resident memory of the workers currently running in one pool:</p>



<pre class="wp-block-code"><code>ps -eo rss,args | grep '&#91;p]hp-fpm: pool wpuser' 
  | awk '{sum+=$1; n++} END {if (n) printf "%d workers, avg %.0f MBn", n, sum/n/1024}'</code></pre>



<p class="wp-block-paragraph">The bracket trick in the grep pattern stops grep matching its own process. <code>rss</code> is resident set size in kilobytes, which is why the awk divides twice. Run it during a busy period, not at three in the morning, and run it a few times.</p>



<p class="wp-block-paragraph">Two honest caveats about RSS. It overstates real consumption, because workers forked from the same master share a lot of pages, including the OPcache region. And it understates your worst case, because a worker that has not yet hit a memory-hungry request looks cheap. Treat the average as a planning figure, not a guarantee, and leave headroom.</p>



<p class="wp-block-paragraph">Then the arithmetic:</p>



<ol class="wp-block-list">

<li>Take total RAM and subtract what MySQL or MariaDB, the web server, mail, and the panel itself need. On a WordPress box the database is usually the second-largest consumer and it is not optional.</li>


<li>Reserve a further slice for page cache and burst. I aim to leave a comfortable margin free rather than allocating to the last megabyte.</li>


<li>Divide what is left by your measured per-worker figure. That is your ceiling for <em>all</em> pools combined.</li>


<li>Split that ceiling across accounts by actual traffic, not evenly.</li>

</ol>



<p class="wp-block-paragraph">Step three is where people go wrong on cheap VPS plans. If you are running a dozen WordPress sites on one of the low-RAM tiers from a provider like Contabo or InterServer, the honest answer is often that you cannot give every account a generous pool and you need to either cut the site count or move up a tier. Setting <code>pm.max_children</code> to a number your RAM cannot back does not buy capacity. It buys the OOM killer, and the OOM killer usually takes the database with it, which turns a slow site into a broken one.</p>



<p class="wp-block-paragraph">To raise the server-wide default:</p>



<pre class="wp-block-code"><code>da config-set php_fpm_max_children_default 30
systemctl restart directadmin
da build rewrite_confs
systemctl restart php-fpm83</code></pre>



<p class="wp-block-paragraph">For a single account, go to <em>Admin Level &rarr; Custom HTTPD Configurations &rarr; domain.com</em> and open the <code>php-fpm</code> entry for that PHP version. In the <code>|CUSTOM1|</code> textarea, set the token:</p>



<pre class="wp-block-code"><code>|?MAX_CHILDREN=40|</code></pre>



<p class="wp-block-paragraph">That syntax is a template token override, not a config line. It tells the template renderer to use 40 instead of the default when it writes <code>pm.max_children</code> for that user. Writing <code>pm.max_children = 40</code> in that box instead is the single most common mistake here, and it does something worse than nothing: the template still emits its own <code>pm.max_children</code>, you now have the directive twice in one pool, and FPM refuses to start. On a server where one broken pool file stops the whole master, that takes down every site on the box.</p>



<p class="wp-block-paragraph">Rewrite and restart after any change, then confirm the value actually landed in the generated file rather than trusting the panel.</p>



<h2 class="wp-block-heading">Per-site PHP limits, and the difference that matters</h2>



<p class="wp-block-paragraph">The <code>|CUSTOM2|</code> textarea on the same page is where PHP settings go. The syntax is FPM pool syntax, not php.ini syntax:</p>



<pre class="wp-block-code"><code>php_admin_value&#91;memory_limit] = 512M
php_admin_value&#91;max_execution_time] = 120
php_value&#91;upload_max_filesize] = 64M
php_value&#91;post_max_size] = 64M
php_value&#91;max_input_vars] = 5000</code></pre>



<p class="wp-block-paragraph">The <code>admin</code> in <code>php_admin_value</code> is not decoration. Settings written with <code>php_value</code> can be raised at runtime by the application through <code>ini_set()</code>. Settings written with <code>php_admin_value</code> cannot. That distinction is the whole game on a multi-tenant server.</p>



<p class="wp-block-paragraph">WordPress and a good number of plugins will happily try to raise <code>memory_limit</code> at runtime, and some optimisation plugins push it to values that would let one bad request consume a large slice of the machine. If you want a genuine ceiling, use <code>php_admin_value</code>. If you want a sensible starting point that a developer can raise for a legitimate import job, use <code>php_value</code>. I use admin for memory and execution time, plain for upload sizes, because upload sizes are a support-ticket generator and not a stability risk.</p>



<p class="wp-block-paragraph"><code>max_input_vars</code> deserves its own note. It is the one that silently truncates a large WooCommerce product save or a menu with hundreds of items, with no error anywhere. The user reports &#8220;my changes did not save&#8221; and every log is clean. If you support content-heavy sites, raise it before someone asks.</p>



<h3 class="wp-block-heading">Letting site owners set their own</h3>



<p class="wp-block-paragraph">Under FPM there is no <code>.htaccess</code> route for PHP settings, because FPM is a separate process and never sees the file. DirectAdmin enables the per-directory alternative by default: a <code>.user.ini</code> file in <code>public_html</code>, controlled by the <code>user_ini.filename</code> setting. It handles the <code>PHP_INI_PERDIR</code> and <code>PHP_INI_USER</code> class of settings only, so it will never override anything you set with <code>php_admin_value</code>. That is the point of it.</p>



<h3 class="wp-block-heading">The WP-CLI gap</h3>



<p class="wp-block-paragraph">None of this applies to WP-CLI. Cron jobs and shell commands run through the CLI SAPI and read the CLI php.ini, not your pool. So a plugin update that works fine in the browser can die on memory in a cron-driven <code>wp cron event run</code>, and you will hunt through the pool config finding nothing wrong. Check the CLI limits separately when a scheduled task fails but the same action succeeds in wp-admin.</p>



<h2 class="wp-block-heading">OPcache is shared, and that is the invisible one</h2>



<p class="wp-block-paragraph">OPcache allocates its shared memory region when the FPM master starts, before any pool exists. Every pool under that master inherits the same region. You cannot meaningfully give one account its own OPcache budget through a pool directive, because by the time the pool is read the memory has already been carved out.</p>



<p class="wp-block-paragraph">Two consequences, and the second is the one nobody plans for.</p>



<p class="wp-block-paragraph">First, capacity. The default OPcache size is comfortable for one application and thin for thirty WordPress installs, each with its own core, theme and plugin tree. When it fills, OPcache starts evicting and eventually restarts, and every site on the version gets slower at once with nothing in any error log to explain it. Watch the wasted-memory and restart counters rather than guessing.</p>



<p class="wp-block-paragraph">Second, the sizing you actually need scales with the <em>total</em> number of PHP files across all accounts on that PHP version, not the biggest site. Consolidating everyone onto one PHP version looks tidy and quietly makes this worse. Spreading accounts across two or three PHP versions gives each master its own region, which is an underrated side effect of not forcing everyone to upgrade on the same day.</p>



<p class="wp-block-paragraph">OPcache is configured through CustomBuild rather than the pool. Edit the customised copy under CustomBuild&#8217;s <code>custom</code> directory so your changes survive updates, then rebuild and restart the FPM master.</p>



<h2 class="wp-block-heading">Making changes survive the next rebuild</h2>



<p class="wp-block-paragraph">Anything you type directly into a generated file is temporary. The next <code>rewrite_confs</code>, PHP update or panel update will flatten it, usually weeks later, and the regression will look like it came from nowhere.</p>



<p class="wp-block-paragraph">There are three durable places to put changes, in increasing order of blast radius:</p>



<ul class="wp-block-list">

<li><strong>Per user, all PHP versions.</strong> <code>/usr/local/directadmin/data/users/USERNAME/php/php-fpm.conf.custom1</code> and <code>.custom2</code>. These feed the same CUSTOM tokens as the panel textareas, so the settings follow the account when the user switches PHP version in Domain Setup. This is the one I reach for first.</li>


<li><strong>Server-wide, all users, all versions.</strong> <code>/usr/local/directadmin/data/templates/custom/php-fpm.conf.custom1</code> and <code>.custom2</code>. If either exists, its contents are injected into every pool file DirectAdmin writes. Good for a baseline you want everywhere.</li>


<li><strong>A full custom template.</strong> Copy <code>/usr/local/directadmin/data/templates/php-fpm.conf</code> to the <code>custom</code> subdirectory and edit it. This is the only route for pool directives that have no token, but you now own that file forever and stop receiving upstream template improvements. Use it when nothing else works, and leave a comment at the top saying why.</li>

</ul>



<p class="wp-block-paragraph">Global first, then per-user on top. Both land in the same token, so a per-user value does not replace a global one, it appends. For a directive that can only appear once in a pool, that is a duplicate and a failed restart.</p>



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



<h3 class="wp-block-heading">&#8220;server reached max_children setting&#8221;</h3>



<p class="wp-block-paragraph">The pool name in that warning tells you which account, not which site. Find the log first rather than guessing at a path:</p>



<pre class="wp-block-code"><code>grep -i 'error_log' /usr/local/php83/etc/php-fpm.conf</code></pre>



<p class="wp-block-paragraph">Before raising the limit, check whether the requests are legitimate. Cross-reference the timestamps against each domain&#8217;s access log in that account. Nine times out of ten it is one of three things: an uncached <code>admin-ajax.php</code> loop from a plugin, a bot crawling faceted search or filtered archive URLs that bypass page cache, or <code>wp-cron.php</code> firing on every request on a site with a heavy scheduled task. Raising <code>pm.max_children</code> to absorb bot traffic is paying for garbage with RAM.</p>



<h3 class="wp-block-heading">502 or 504 with no PHP error</h3>



<p class="wp-block-paragraph">The request is dying at the FPM boundary rather than inside PHP. Usual causes are a worker killed by <code>request_terminate_timeout</code>, a full listen queue, or the master failing to start after a config change. Check the FPM service status before you check anything in WordPress. If FPM did not start at all, the panel will still show your settings saved and every site on that PHP version will be down.</p>



<h3 class="wp-block-heading">Changes in the panel that do nothing</h3>



<p class="wp-block-paragraph">Read the generated per-user file after every change. If the value is not in there, the rewrite did not run or the token was wrong. If it is in there and PHP still reports something else, the master was not restarted, or something later in the pool is overriding it. A temporary <code>phpinfo()</code> file served by the site in question settles the argument in seconds. Delete it immediately afterwards.</p>



<h3 class="wp-block-heading">Session or temp files that are not where you left them</h3>



<p class="wp-block-paragraph">If PHP writes to <code>/tmp</code> and you cannot see the files over SSH, that is systemd&#8217;s <code>PrivateTmp</code> giving the FPM service its own private namespace. Nothing is broken. The files exist, in a mount only that process can see.</p>



<h3 class="wp-block-heading">disable_functions that will not budge</h3>



<p class="wp-block-paragraph"><code>disable_functions</code> is special. From a pool you can add to the list but not remove from it, because the php.ini value has already been applied. To allow a function for one account you have to stop setting it in php.ini at all and drive the whole thing from templates instead. DirectAdmin documents that pattern, and it is worth reading before you start rather than after you have half-done it, because the intermediate state removes protection server-wide.</p>



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



<ul class="wp-block-list">

<li>Editing <code>/usr/local/phpXX/etc/php-fpm.conf</code> directly and being surprised when it reverts.</li>


<li>Writing <code>pm.max_children = 40</code> into a CUSTOM box instead of the <code>|?MAX_CHILDREN=40|</code> token, producing a duplicate directive and a dead master.</li>


<li>Sizing workers from a memory figure found online rather than measured on the box.</li>


<li>Using <code>php_value</code> for <code>memory_limit</code> on shared hosting, then wondering how a plugin raised it to 2G.</li>


<li>Assuming pool settings apply to WP-CLI and cron.</li>


<li>Raising limits to absorb bot traffic instead of blocking or caching it upstream.</li>


<li>Putting a client&#8217;s revenue site in the same account as their abandoned projects.</li>

</ul>



<h2 class="wp-block-heading">Best practices for tuning DirectAdmin PHP-FPM pools</h2>



<ul class="wp-block-list">

<li>One DirectAdmin user per site that matters. Isolation you get for free beats isolation you have to engineer.</li>


<li>Keep <code>ondemand</code> on shared servers. Only move to <code>dynamic</code> when a single busy site owns the box.</li>


<li>Set a global baseline in the custom template, then override per user. Do not hand-tune forty files.</li>


<li>Use <code>php_admin_value</code> for anything that protects the server and <code>php_value</code> for anything that is only a convenience.</li>


<li>Cut PHP requests before you add workers. Full-page caching, a CDN in front, and Cloudflare or similar handling bots removes far more load than any pool setting.</li>


<li>Disable WordPress&#8217;s request-triggered cron and drive it from a real system cron, so scheduled work stops competing with visitors for workers.</li>


<li>Alert on saturation, not just uptime. Worker utilisation per pool and PHP-FPM error log entries are the signals worth having in Netdata, Prometheus, or whatever you already run. A site that is queueing looks perfectly healthy to an HTTP check.</li>


<li>Re-measure after major plugin changes. A page builder or a new security plugin moves per-worker memory enough to invalidate your arithmetic.</li>

</ul>



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



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



<h3 class="wp-block-heading">Can I create a separate PHP-FPM pool for each domain in DirectAdmin?</h3>



<p class="wp-block-paragraph">Not through the panel. Pools are generated per user, per PHP version, and every domain in the account shares one. The supported ways to get separation are to move the domain to its own DirectAdmin user, or to enable isolated FPM mode for jailed users, which gives each user their own master process. Anything else means custom systemd units you maintain yourself, outside the panel&#8217;s knowledge.</p>



<h3 class="wp-block-heading">What is a good pm.max_children value for WordPress?</h3>



<p class="wp-block-paragraph">There is no portable number, because the input is your measured per-worker memory and your available RAM after the database. Measure the average resident size of your workers under real traffic, subtract everything else the server needs from total RAM, and divide. A lean brochure site and a WooCommerce store with thirty plugins can differ by a factor of three or more.</p>



<h3 class="wp-block-heading">Where does DirectAdmin store PHP-FPM pool configuration?</h3>



<p class="wp-block-paragraph">Generated per-user pools live at <code>/usr/local/directadmin/data/users/USERNAME/php/php-fpmXX.conf</code>, rendered from the template at <code>/usr/local/directadmin/data/templates/php-fpm.conf</code>. Do not edit either directly. Put durable changes in the custom files under the user&#8217;s <code>php</code> directory or in the <code>templates/custom</code> directory.</p>



<h3 class="wp-block-heading">Why do my PHP settings disappear after a CustomBuild update?</h3>



<p class="wp-block-paragraph">Because they were written into a generated file. CustomBuild and DirectAdmin regenerate those files on update and on any config rewrite. Only the custom template files and the CUSTOM token areas persist.</p>



<h3 class="wp-block-heading">Does .htaccess work for PHP settings under PHP-FPM?</h3>



<p class="wp-block-paragraph">No. FPM is a separate process from the web server and never reads <code>.htaccess</code>. Use a <code>.user.ini</code> file in the web root for the settings PHP allows at that level, or set the rest in the pool.</p>



<h3 class="wp-block-heading">Should I switch from ondemand to dynamic or static?</h3>



<p class="wp-block-paragraph">On a shared server, no. Ondemand exists precisely so that dozens of idle pools cost you nothing. On a server dedicated to one high-traffic site, <code>dynamic</code> removes cold-start latency and <code>static</code> gives the most predictable memory profile. The question to ask is whether your idle pools outnumber your busy ones.</p>



<h3 class="wp-block-heading">Can I give one WordPress site its own OPcache?</h3>



<p class="wp-block-paragraph">Not from the pool config. OPcache&#8217;s shared memory is allocated by the FPM master at startup and shared by every pool under it. The practical routes are isolated FPM mode, which gives jailed users their own master, or moving the site to a different PHP version so it sits under a different master.</p>



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



<p class="wp-block-paragraph">DirectAdmin PHP-FPM pools are a per-user boundary, not a per-site one. Every tuning decision that follows, how many workers to allow, whether a memory limit should be a suggestion or a wall, whether a client&#8217;s twelve domains belong in one account, comes out of that single fact.</p>



<p class="wp-block-paragraph">Measure your workers before you size them, use the CUSTOM token files so your work survives the next rebuild, and watch pool saturation rather than waiting for the 502 ticket. The site that goes down is rarely the site that caused it.</p>



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



<h2 class="wp-block-heading">Need a hand with a DirectAdmin server?</h2>



<p class="wp-block-paragraph">Most of the work I do on DirectAdmin boxes is this kind of thing: sites that are fine in isolation and fall over together. If that sounds familiar, I can help with:</p>



<ul class="wp-block-list">

<li>Auditing every pool on a server and producing a worker budget that matches the RAM you actually have</li>


<li>Tracking down which domain in a shared account is saturating the pool, and why</li>


<li>Building a global custom template so per-user tuning stops being forty hand-edited files</li>


<li>Splitting a multi-site DirectAdmin account into isolated users without breaking mail, DNS or SSL</li>


<li>OPcache and per-version sizing on servers hosting many WordPress installs</li>


<li>Wiring up pool-level monitoring and alerts so saturation shows up before the ticket does</li>

</ul>



<p class="wp-block-paragraph">Send me a pool file, the output of that RSS command, or the last hour of your PHP-FPM error log, and I will tell you what I see.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/directadmin-php-fpm-pools-wordpress-tuning/">DirectAdmin PHP-FPM Pools: Why One WordPress Site Takes Down the Others</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Optimizing API Calls to Reduce SaaS Costs: Six Levers That Actually Move the Bill</title>
		<link>https://john-nessime.com/blog/devops/reduce-saas-api-costs/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 14 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[API Cost Optimization]]></category>
		<category><![CDATA[Batch Processing]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Cache Stampede]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Conditional Requests]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[ETag]]></category>
		<category><![CDATA[Exponential Backoff]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[HTTP Headers]]></category>
		<category><![CDATA[Idempotency]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Polling]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[Redis]]></category>
		<category><![CDATA[REST API]]></category>
		<category><![CDATA[Retry Budgets]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Vendor Spend]]></category>
		<category><![CDATA[Webhooks]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=229</guid>

					<description><![CDATA[<p>Third-party API spend is the one production signal with no error rate attached to it, which is why it creeps up quietly. This is a working engineer's guide to reducing SaaS API costs by changing the shape of your calls: reading the billing unit before you optimise anything, killing pointless polling with conditional requests and webhooks, collapsing N+1 patterns, caching with stampede protection and per-tenant keys, stopping your own retry amplification, and attributing spend so you can prove the work paid off.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/reduce-saas-api-costs/">Optimizing API Calls to Reduce SaaS Costs: Six Levers That Actually Move the Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The message usually comes from finance, not from monitoring. A vendor line on the card went up again, nobody shipped a feature that month, and now somebody wants an explanation by Friday.</p>



<p class="wp-block-paragraph">Here is the awkward part: nothing was broken. No alert fired, no error rate moved, no dashboard turned red. Third-party API spend is one of the few production signals with no failure mode attached to it. A background job that polls a vendor every thirty seconds and gets back the exact same payload it got last time looks, from the outside, identical to a healthy integration. It just costs money every single time.</p>



<p class="wp-block-paragraph">This post is about how to reduce SaaS API costs by changing the shape of your calls rather than by renegotiating your contract. Six levers, in the order I&#8217;d pull them: read the billing unit, kill pointless polling, collapse N+1 patterns, cache honestly, stop your own retry amplification, and attribute spend so you can tell whether any of it worked.</p>



<h2 class="wp-block-heading">Start with the billing unit, not the call count</h2>



<p class="wp-block-paragraph">The single most common mistake I see is optimising HTTP requests when the vendor doesn&#8217;t bill HTTP requests. Before you touch any code, read the pricing page carefully enough to answer one question: <em>what is the countable thing?</em></p>



<p class="wp-block-paragraph">It varies more than people expect:</p>



<ul class="wp-block-list">
<li><strong>Per request.</strong> The obvious one. Geocoding, enrichment, most REST endpoints.</li>

<li><strong>Per session.</strong> Google Maps Platform bills Places Autocomplete this way when you pass a session token: the whole keystroke stream plus the terminating Place Details call is grouped and billed as one unit. Omit the token, or reuse one across sessions, and every keystroke request is billed individually.</li>

<li><strong>Per token issued.</strong> Auth0&#8217;s machine-to-machine quotas count access tokens obtained through the client credentials flow, not the API calls you make with them. A service that fetches a fresh token on every outbound request is burning the meter for no reason.</li>

<li><strong>Per input and output token.</strong> LLM providers. Output is typically priced higher than input, which makes &#8220;be concise&#8221; a cost control and not just a style note.</li>

<li><strong>Per record, event, or message.</strong> Twilio, SendGrid, most streaming and messaging platforms. One API call can carry many billable units.</li>

<li><strong>Not at all.</strong> Stripe doesn&#8217;t bill you per API call; the constraint there is a rate limit, not a line item. Optimising those calls buys you reliability headroom, not money.</li>
</ul>



<p class="wp-block-paragraph">Getting this wrong sends you down the wrong path for weeks. Batching requests against an endpoint that bills per record changes your request count and leaves the invoice exactly where it was. Read the unit first.</p>



<h2 class="wp-block-heading">Lever one: stop polling for things that rarely change</h2>



<p class="wp-block-paragraph">This is almost always the biggest single line, and it&#8217;s invisible because it&#8217;s steady. A sync job on a five-minute cron is 288 calls a day per resource. Multiply by resources, by environments, by the replicas you quietly scaled to three, and you get a number with no relationship at all to how often the data changed.</p>



<h3 class="wp-block-heading">Conditional requests, when the vendor honours them</h3>



<p class="wp-block-paragraph">If the API returns an <code>ETag</code> or <code>Last-Modified</code> header, store it and send it back on the next poll as <code>If-None-Match</code> or <code>If-Modified-Since</code>. If nothing changed you get a <code>304 Not Modified</code> with an empty body.</p>



<pre class="wp-block-code"><code># First call: capture the ETag from the response headers
curl -sS -D - -o /dev/null 
  -H "Authorization: Bearer $TOKEN" 
  https://api.github.com/repos/OWNER/REPO/pulls

# Second call: send it back and check the status code only
curl -sS -o /dev/null -w '%{http_code}n' 
  -H "Authorization: Bearer $TOKEN" 
  -H 'If-None-Match: "PASTE_THE_ETAG_HERE"' 
  https://api.github.com/repos/OWNER/REPO/pulls</code></pre>



<p class="wp-block-paragraph"><code>-D -</code> dumps response headers to stdout so you can see the <code>etag</code> value. <code>-w '%{http_code}'</code> on the second call prints just the status, which is all you care about: <code>304</code> means the resource is unchanged and you saved a payload.</p>



<p class="wp-block-paragraph">Whether that saves <em>money</em> depends on the vendor. GitHub documents that a conditional request returning 304 doesn&#8217;t count against your primary rate limit when it&#8217;s correctly authorised. Plenty of other vendors return 304 happily and bill it anyway. Test against your own invoice before you assume.</p>



<p class="wp-block-paragraph">Three things that bite people with ETags:</p>



<ul class="wp-block-list">
<li>They&#8217;re per-page on paginated endpoints. A 304 on page one says nothing about pages two through five, so store one ETag per page.</li>

<li>They can be scoped to the credential. Rotate a token and your stored ETags may stop matching, which quietly turns your conditional requests back into full ones.</li>

<li>GraphQL endpoints generally don&#8217;t do conditional requests at all. You cache those yourself, keyed on a hash of the query and its variables.</li>
</ul>



<h3 class="wp-block-heading">Webhooks, when they exist</h3>



<p class="wp-block-paragraph">The proper fix is to stop asking. If the vendor supports webhooks, the call count collapses from &#8220;one per interval&#8221; to &#8220;one per actual change&#8221;, which is usually a difference of two orders of magnitude on a slow-moving resource.</p>



<p class="wp-block-paragraph">Be honest about what it costs, though. Webhooks need a publicly reachable endpoint, signature verification, idempotent handling because vendors redeliver, a dead-letter path, and a reconciliation sweep for deliveries that never arrived. On a resource that changes every few seconds, polling is simpler and the savings are small. On one that changes twice a week, the webhook pays for itself immediately.</p>



<p class="wp-block-paragraph">A middle option people forget: many APIs expose a cheap &#8220;has anything changed&#8221; endpoint, a sync cursor, or a delta token. Poll that on a tight interval and only fetch the expensive payload when it moves.</p>



<h2 class="wp-block-heading">Lever two: collapse the N+1 pattern</h2>



<p class="wp-block-paragraph">You know this one from databases, and it shows up identically against third-party APIs. Fetch a list of 200 items, then fetch each item&#8217;s detail individually: that&#8217;s 201 billable calls where the vendor probably offers a way to do it in one or two. Look for:</p>



<ul class="wp-block-list">
<li><strong>Expansion or embed parameters.</strong> Many REST APIs let you inline related objects in the list response so the detail calls disappear entirely.</li>

<li><strong>Field selection.</strong> Asking for fewer fields is free on per-request billing but matters a lot where the field tier drives the price. Google Places is explicit about this: requesting a single higher-tier field upgrades the whole response to that tier&#8217;s pricing.</li>

<li><strong>Bulk and batch endpoints.</strong> Salesforce, Algolia, most CRMs and search platforms have one. They&#8217;re usually documented in a corner nobody reads.</li>

<li><strong>Larger pages.</strong> If the endpoint accepts a page size of 200 and you&#8217;re defaulting to 25, you&#8217;re paying eight times as many calls to fetch identical data.</li>
</ul>



<p class="wp-block-paragraph">The page-size one is the cheapest win in this entire post and it&#8217;s a one-line change. Check your client library defaults today.</p>



<h2 class="wp-block-heading">Lever three: cache with a boring, honest TTL</h2>



<p class="wp-block-paragraph">Caching third-party responses is the lever everyone reaches for first and gets subtly wrong. Three failure modes matter more than the cache itself.</p>



<p class="wp-block-paragraph"><strong>Stampede.</strong> A popular key expires, fifty concurrent requests all miss, and fifty identical calls go out to the vendor. You just paid fifty times for one refresh, at the worst possible moment.</p>



<p class="wp-block-paragraph"><strong>No negative caching.</strong> A lookup that returns &#8220;not found&#8221; is usually not cached at all, so the same failing lookup hammers the vendor forever. Cache your 404s. Short TTL, but cache them.</p>



<p class="wp-block-paragraph"><strong>Cache key design.</strong> If your key doesn&#8217;t include the credential or tenant, you will eventually serve one customer&#8217;s data to another. This is the one mistake in this post that turns a cost problem into an incident.</p>



<p class="wp-block-paragraph">You don&#8217;t necessarily need application code for this. An Nginx instance in front of the vendor handles all three, and it&#8217;s the approach I reach for first when several services hit the same API:</p>



<pre class="wp-block-code"><code>proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:20m
                 max_size=1g inactive=60m use_temp_path=off;

server {
    listen 127.0.0.1:8080;

    location / {
        proxy_pass https://api.vendor.example;
        proxy_set_header Host api.vendor.example;

        proxy_cache api_cache;
        proxy_cache_key "$request_method$request_uri$http_authorization";

        proxy_cache_valid 200 5m;
        proxy_cache_valid 404 30s;

        proxy_cache_lock on;
        proxy_cache_use_stale updating error timeout;
        proxy_cache_background_update on;

        add_header X-Cache-Status $upstream_cache_status;
    }
}</code></pre>



<p class="wp-block-paragraph">Reading that from the top down, because every directive is there for a reason:</p>



<ul class="wp-block-list">
<li><code>proxy_cache_key</code> includes <code>$http_authorization</code>, so responses are isolated per credential. That&#8217;s the tenant-leak fix.</li>

<li><code>proxy_cache_valid 404 30s</code> is the negative caching. Failed lookups stop being free traffic for the vendor.</li>

<li><code>proxy_cache_lock on</code> is the stampede fix. Only the first request for a given key goes upstream; the rest wait for it.</li>

<li><code>proxy_cache_use_stale updating</code> plus <code>proxy_cache_background_update on</code> serves the stale copy while a single background request refreshes it. Latency stays flat and you don&#8217;t pay a thundering herd at every expiry.</li>

<li><code>X-Cache-Status</code> gives you <code>HIT</code>, <code>MISS</code>, <code>STALE</code> or <code>UPDATING</code> in the response so you can actually measure your hit rate instead of guessing at it.</li>
</ul>



<p class="wp-block-paragraph">The trade-off is worth stating plainly: you&#8217;re now serving data that can be five minutes stale, and you own another moving part. Fine for a currency rate or a product catalogue. Not fine for an inventory count on a checkout page. Set the TTL from how wrong the data is allowed to be, never from how much you want to save.</p>



<p class="wp-block-paragraph">Where the caller is distributed, Redis with a short lock key does the same job in application code, and a small always-on VPS from something like InterServer or DigitalOcean is plenty to host either the proxy or the cache. At the edge, Cloudflare Workers with KV lets you cache vendor responses close to users, which helps when the calls originate from browsers rather than your backend.</p>



<h2 class="wp-block-heading">Lever four: stop paying for your own retries</h2>



<p class="wp-block-paragraph">This produces the sudden spike rather than the slow creep, and people miss it because retries feel like resilience.</p>



<p class="wp-block-paragraph">The vendor wobbles. Every client retries. With a fixed delay they retry in lockstep, so the vendor gets a synchronised wave on top of normal load, struggles longer, and generates more retries. AWS&#8217;s architecture write-up on exponential backoff and jitter is the canonical reference, and its central finding is worth repeating: backoff alone still leaves visible clusters of calls. Randomising each client&#8217;s delay is what decorrelates the herd, and in their simulation it cut total call count substantially.</p>



<pre class="wp-block-code"><code>import random, time

BASE = 0.5      # seconds
CAP  = 30.0     # never sleep longer than this

def sleep_before_retry(attempt):
    # capped exponential backoff, then full jitter
    window = min(CAP, BASE * (2 ** attempt))
    time.sleep(random.uniform(0, window))</code></pre>



<p class="wp-block-paragraph">The cap stops a long outage turning into hour-long sleeps. The <code>random.uniform(0, window)</code> is the jitter: two processes that failed at the same instant now wake at different times. Note that full jitter can occasionally produce a near-zero delay, which is fine for transient errors but too aggressive for throttling responses that reset on a per-minute window. For a <code>429</code>, honour the <code>Retry-After</code> header if the vendor sends one, and use a jitter formula with a guaranteed floor if they don&#8217;t.</p>



<p class="wp-block-paragraph">Three more rules that cost nothing to implement:</p>



<ol class="wp-block-list">
<li><strong>Never retry a 4xx except 429.</strong> A 400 or a 422 will fail identically forever. Every one of those retries is a billed call buying you nothing.</li>

<li><strong>Cap retries as a fraction of traffic.</strong> A retry budget (say, retries may not exceed some small percentage of total requests) is the only control that sees the system-wide picture. Per-client backoff can&#8217;t.</li>

<li><strong>Check your layers.</strong> If the SDK retries three times, your HTTP client retries three times, and your job runner retries three times, one logical operation is up to twenty-seven calls. This stacking is extremely common and almost never intentional.</li>
</ol>



<p class="wp-block-paragraph">For writes, retrying safely needs an idempotency key so the vendor deduplicates rather than creating two of whatever you asked for. Stripe&#8217;s <code>Idempotency-Key</code> header is the pattern most others copied. Without it, teams disable retries on writes entirely and then eat manual reconciliation work instead.</p>



<h2 class="wp-block-heading">Lever five: move latency-tolerant work off the synchronous path</h2>



<p class="wp-block-paragraph">Ask a blunt question about each integration: does a human wait on this response?</p>



<p class="wp-block-paragraph">Nightly enrichment, classification, report generation, bulk address validation, embedding backfills. Nobody is waiting. Several vendors, including the major LLM providers, offer asynchronous batch endpoints at a discount to the synchronous rate in exchange for a looser delivery window. Rates move, so check current terms rather than a number you read somewhere, but the mechanism is stable and the discount is usually substantial.</p>



<p class="wp-block-paragraph">Even without a batch endpoint, moving work behind a queue lets you coalesce. Ten events touching the same customer record within a minute become one enrichment call instead of ten. That deduplication window is often worth more than the batch discount.</p>



<h2 class="wp-block-heading">Lever six: attribute the spend or you&#8217;re guessing</h2>



<p class="wp-block-paragraph">Everything above is unfalsifiable until you can answer: which feature, which tenant, which job. The invoice gives you the total and never the cause. What I&#8217;d put in place, in order:</p>



<ol class="wp-block-list">
<li><strong>Route outbound calls through one place.</strong> A shared HTTP client wrapper or the caching proxy above. If every service instantiates its own client, you have no chokepoint and no measurement.</li>

<li><strong>Emit a counter per call</strong> with labels for vendor, endpoint, status class, and cache result. Keep the label set small; per-tenant labels will blow up your metrics cardinality and trade one bill for another.</li>

<li><strong>Build one unit-cost metric.</strong> Vendor spend per active tenant per month. It&#8217;s the only number that survives growth, because raw spend going up during a good quarter is not a problem.</li>

<li><strong>Alert on call rate, not on the bill.</strong> A rate alert fires in minutes. An invoice fires in thirty days. Grafana, Prometheus, Datadog, CloudWatch, whatever you already run is fine; the point is that the alert exists at all.</li>

<li><strong>Track cache hit rate as a first-class SLI.</strong> When someone changes a cache key format and the hit rate silently drops to zero, this is the graph that tells you before the invoice does.</li>
</ol>



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



<h2 class="wp-block-heading">Troubleshooting: the bill jumped and nothing shipped</h2>



<p class="wp-block-paragraph">Work down this list. In my experience the cause is usually in the first four.</p>



<ul class="wp-block-list">
<li><strong>Replica count changed.</strong> Autoscaling or a Kubernetes replica bump multiplies every cron-driven poller. Check whether your scheduled jobs have leader election, or whether all five pods are dutifully polling the same endpoint.</li>

<li><strong>Cache hit rate collapsed.</strong> A changed key format, a Redis eviction policy under memory pressure, or a TTL someone dropped &#8220;temporarily&#8221; during debugging. Graph the hit rate over the period, not the current value.</li>

<li><strong>Retry loop against a persistent 4xx.</strong> Look for a flat, high rate of identical failing calls. This has the distinctive signature of high call volume with zero successful responses.</li>

<li><strong>A non-production environment pointed at production credentials.</strong> Staging, a CI pipeline, or an integration test suite running on every commit. Check whether calls are arriving from IPs you don&#8217;t recognise.</li>

<li><strong>Session or token handling regressed.</strong> A refactor that dropped a session token parameter, or a service that stopped caching its auth token and now mints a new one per request.</li>

<li><strong>Legitimate growth in an expensive segment.</strong> A few large customers using a costly feature move the bill without moving call count much. Not a bug, but worth ruling in before you go hunting for one.</li>
</ul>



<p class="wp-block-paragraph">If the vendor exposes per-key usage breakdowns in their dashboard, split your credentials by service before your next incident. Separate API keys per environment and per major consumer turn a whodunnit into a two-minute lookup.</p>



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



<ul class="wp-block-list">
<li>Optimising the endpoint that&#8217;s easy to change rather than the one that dominates the bill. Rank by spend first.</li>

<li>Caching without a per-credential key, and finding out the hard way in a multi-tenant system.</li>

<li>Treating 304 responses as free when your specific vendor bills them anyway.</li>

<li>Setting TTLs from a savings target rather than a staleness tolerance, and finding out during a customer escalation.</li>

<li>Leaving retry logic enabled at three separate layers and calling the result &#8220;defence in depth&#8221;.</li>

<li>Adding per-tenant labels to outbound-call metrics and moving the cost from the API vendor to the observability vendor.</li>

<li>Shipping the optimisation and never checking the next invoice against the prediction.</li>
</ul>



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



<ul class="wp-block-list">
<li>One outbound HTTP client wrapper per service, with timeouts, retries, and metrics configured in exactly one place.</li>

<li>Separate API credentials per environment and per major consumer, so usage dashboards mean something.</li>

<li>Cache auth tokens until shortly before expiry. Refresh early, not on every call.</li>

<li>Add jitter to scheduled jobs as well as to retries. Everything set to run at the top of the hour arrives at the vendor as a spike.</li>

<li>Write down the staleness tolerance for each cached resource next to its TTL, so the next person doesn&#8217;t have to reverse-engineer your reasoning.</li>

<li>Re-check the pricing page after any vendor product update. Billing units change more often than endpoints do.</li>
</ul>



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



<h3 class="wp-block-heading">How do I find out which service is driving my API spend?</h3>



<p class="wp-block-paragraph">Start with the vendor&#8217;s own usage dashboard broken down by API key. If all your services share one key, that&#8217;s the first thing to fix. Failing that, log every outbound call at the client wrapper with the endpoint and calling service, and aggregate for a week. A week of honest logs beats a month of theorising.</p>



<h3 class="wp-block-heading">Do ETags actually reduce SaaS API costs, or just help with rate limits?</h3>



<p class="wp-block-paragraph">It depends entirely on the vendor. GitHub documents that an authorised conditional request returning 304 doesn&#8217;t count against the primary rate limit. Others count every request regardless of status. Run a controlled test: a known number of conditional calls over a billing period, then compare against the usage report. Don&#8217;t assume either way.</p>



<h3 class="wp-block-heading">Is caching third-party API responses allowed?</h3>



<p class="wp-block-paragraph">Usually yes, but not always, and some terms of service restrict how long you may store certain data. Mapping and place data is a common example of restricted caching. Check the terms for the specific vendor before you build a permanent store, particularly if you&#8217;re caching anything personally identifiable.</p>



<h3 class="wp-block-heading">How much can caching realistically cut an API bill?</h3>



<p class="wp-block-paragraph">Nobody can give you a number without knowing your traffic. Estimate it yourself: for a candidate endpoint, count distinct request keys versus total requests over a day. That ratio is roughly your ceiling for hit rate. Multiply by the endpoint&#8217;s share of spend. If the answer is a couple of percent, spend your time elsewhere.</p>



<h3 class="wp-block-heading">Should I always replace polling with webhooks?</h3>



<p class="wp-block-paragraph">No. The comparison is change frequency against poll frequency. If the resource changes almost as often as you poll, you save little and take on signature verification, redelivery handling, and a reconciliation job. Webhooks win decisively on slow-changing resources and on anything where you&#8217;re polling more than a handful of objects.</p>



<h3 class="wp-block-heading">Does rate limiting my own users reduce vendor costs?</h3>



<p class="wp-block-paragraph">Only when user actions map directly to vendor calls, such as a search box that fires an upstream request per keystroke. Debouncing input and enforcing per-plan quotas helps a lot there. For background processing driven by your own schedules, user rate limits change nothing.</p>



<h3 class="wp-block-heading">Should I disable retries to save money?</h3>



<p class="wp-block-paragraph">No. Fix them instead. Capped exponential backoff with jitter, no retries on non-429 client errors, a retry budget, and retry logic living at exactly one layer. Disabling retries entirely trades a cost problem for a reliability problem, and reliability problems are more expensive.</p>



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



<p class="wp-block-paragraph">Most efforts to reduce SaaS API costs fail because they start with code. They should start with two questions: what is the vendor actually counting, and which of my calls returned information I already had?</p>



<p class="wp-block-paragraph">Answer those honestly and the work usually collapses to a handful of unglamorous changes. Raise a page size. Cache a token. Put a lock around a cache refresh. Delete a poller that was watching something that changes twice a week. None of it is clever. All of it shows up on the next invoice, which is the only review that counts.</p>



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



<h2 class="wp-block-heading">Need help getting your API spend under control?</h2>



<p class="wp-block-paragraph">This is work I take on regularly. Typical engagements look like:</p>



<ul class="wp-block-list">
<li>Auditing outbound API traffic to find which endpoints, jobs and tenants are driving the bill, and ranking them by spend rather than by how obvious they are.</li>

<li>Building a caching layer in front of chatty vendor APIs, with Nginx, Redis or Cloudflare Workers, including stampede protection and per-tenant key isolation.</li>

<li>Replacing polling jobs with webhook receivers or delta-token syncs, with signature verification, idempotent handling and a dead-letter path.</li>

<li>Auditing timeout and retry configuration across services to remove stacked retries and add proper backoff, jitter and retry budgets.</li>

<li>Moving latency-tolerant workloads onto batch or queue-based paths, with deduplication windows that cut call volume before it reaches the vendor.</li>

<li>Setting up call-rate and cache-hit-rate dashboards and alerts in Prometheus, Grafana, Datadog or CloudWatch so the invoice is never the first signal.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got a cost graph that&#8217;s climbing, a job you suspect is over-polling, or a client wrapper you&#8217;re not sure about, send it over and I&#8217;ll tell you what I&#8217;d look at first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/reduce-saas-api-costs/">Optimizing API Calls to Reduce SaaS Costs: Six Levers That Actually Move the Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Nginx vs Apache vs Caddy: What Actually Decides It in Real Projects</title>
		<link>https://john-nessime.com/blog/devops/nginx-vs-apache-vs-caddy/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 12 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Automatic HTTPS]]></category>
		<category><![CDATA[Caddy]]></category>
		<category><![CDATA[Caddyfile]]></category>
		<category><![CDATA[Certbot]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[Load Balancing]]></category>
		<category><![CDATA[mod_php]]></category>
		<category><![CDATA[MPM]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Reverse Proxy]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[TLS]]></category>
		<category><![CDATA[VPS]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[Web Server]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=195</guid>

					<description><![CDATA[<p>Benchmarks pick the wrong winner. Here is how Nginx, Apache and Caddy actually differ in production: how each one fails under load, who owns certificate renewal, and where configuration lives when a developer needs to change it. Includes a decision procedure and the mistakes I see most.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/nginx-vs-apache-vs-caddy/">Nginx vs Apache vs Caddy: What Actually Decides It in Real Projects</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A message landed in a channel I was in: &#8220;We&#8217;re standardising on Nginx.&#8221; No traffic profile. No mention of what the current setup was actually failing at. No list of the applications that would have to move. The decision got made because everybody knows Nginx is fast.</p>



<p class="wp-block-paragraph">That framing is where most of these arguments go sideways. On one box serving a normal website, all three of these servers will handle your traffic without breaking a sweat. The web server is almost never the slow part. The difference shows up months later, at 2am, in who gets paged and why.</p>



<p class="wp-block-paragraph">So this post compares <strong>Nginx vs Apache vs Caddy</strong> on the three things that genuinely change your on-call life: how each one behaves when it runs out of capacity, who owns the certificate lifecycle, and where configuration lives when someone other than you needs to change it. Then a decision procedure, the mistakes I keep running into, and an FAQ.</p>



<h2 class="wp-block-heading">Why the benchmark comparison picks the wrong winner</h2>



<p class="wp-block-paragraph">Almost every throughput comparison you&#8217;ll find measures static file serving on an idle machine with no application behind it. That is a real measurement of a thing that is not your bottleneck. If you&#8217;re proxying to PHP-FPM, Gunicorn, or a Node process, the request spends milliseconds in the web server and everything else in your code and your database.</p>



<p class="wp-block-paragraph">The honest exception: if you are running a CDN origin, terminating TLS at a high connection rate, or serving genuinely large volumes of static assets, the connection handling model does show up in your bill. That is a real workload and Nginx has earned its reputation there. It just isn&#8217;t most projects.</p>



<p class="wp-block-paragraph">For everyone else, the useful question is not &#8220;which is fastest&#8221; but &#8220;which chores am I choosing to keep.&#8221; That&#8217;s what the rest of this is about.</p>



<h2 class="wp-block-heading">Failure family one: the process model decides how you fall over</h2>



<p class="wp-block-paragraph">This is the invisible one. Nobody notices it during a migration and everybody notices it during a traffic spike.</p>



<h3 class="wp-block-heading">Apache: a hard worker ceiling, and a queue behind it</h3>



<p class="wp-block-paragraph">Apache&#8217;s behaviour depends entirely on which Multi-Processing Module is loaded. With <code>prefork</code>, you get one process per connection, capped by <code>MaxRequestWorkers</code>. When you hit that cap, new connections don&#8217;t get rejected. They sit in the kernel&#8217;s listen backlog waiting for a worker to free up.</p>



<p class="wp-block-paragraph">The symptom is the worst kind: the site &#8220;hangs.&#8221; Load average is unremarkable. The application log is quiet because the requests never reached the application. Your uptime check times out but a manual curl from the box works fine. People spend hours looking at the database.</p>



<p class="wp-block-paragraph">Check which MPM you&#8217;re actually running before you theorise about anything:</p>



<pre class="wp-block-code"><code># RHEL, AlmaLinux, Rocky
httpd -V | grep -i "Server MPM"

# Debian and Ubuntu
apache2ctl -V | grep -i "Server MPM"</code></pre>



<p class="wp-block-paragraph">The reason prefork survives is <code>mod_php</code>. It isn&#8217;t safe with Apache&#8217;s threaded MPMs, so PHP stacks historically pinned themselves to prefork and inherited its concurrency profile. The Apache project itself now points people at PHP-FPM over <code>mod_proxy_fcgi</code> instead, which frees you to run <code>mpm_event</code> and gets you HTTP/2 as a side effect, since <code>mod_http2</code> disables itself under prefork.</p>



<p class="wp-block-paragraph">If you inherit an Apache box, that migration is usually the single highest-value change available to you, and it does not require leaving Apache.</p>



<h3 class="wp-block-heading">Nginx: cheap connections, different limits</h3>



<p class="wp-block-paragraph">Nginx runs a small number of worker processes, each with an event loop. An idle keepalive connection costs a file descriptor and a little memory, not a process. Ten thousand of them is not a crisis.</p>



<p class="wp-block-paragraph">Its ceiling is <code>worker_processes</code> multiplied by <code>worker_connections</code>, plus whatever your file descriptor limit allows. When you hit it, Nginx says so plainly in the error log rather than silently queueing. That&#8217;s a genuine operational advantage: the failure announces itself.</p>



<p class="wp-block-paragraph">The trade is that when Nginx is proxying, saturation moves downstream. You stop seeing worker exhaustion and start seeing 502s and 504s from an overloaded PHP-FPM pool or app server. The web server is fine; you now have to go find the real limit.</p>



<h3 class="wp-block-heading">Caddy: goroutines, and a softer edge</h3>



<p class="wp-block-paragraph">Caddy is a Go binary and handles connections with goroutines that the runtime multiplexes onto OS threads. Practically, that sits between the other two: connections are cheap like Nginx, but you don&#8217;t tune a worker count at all.</p>



<p class="wp-block-paragraph">The failure mode is correspondingly softer and, honestly, harder to reason about. Instead of a clean &#8220;workers exhausted&#8221; line, you get memory growth and garbage collection pressure. There&#8217;s no knob you turned wrong, which is pleasant right up until you need to explain what happened.</p>



<h2 class="wp-block-heading">Failure family two: who owns the certificate lifecycle</h2>



<p class="wp-block-paragraph">This is the one that actually causes outages, and it is the sharpest genuine difference between the three.</p>



<p class="wp-block-paragraph">With Nginx or Apache, certificate management is a separate moving part. Certbot or another ACME client writes new files to disk on a timer, and the running server keeps serving whatever it loaded at startup. If nothing tells it to reload, the certificate on disk is valid and the certificate on the wire is expired. Users see a browser interstitial while your monitoring, if it checks the filesystem, reports everything fine.</p>



<p class="wp-block-paragraph">The fix is to attach the reload to the renewal itself, not to a separate cron entry that might drift:</p>



<pre class="wp-block-code"><code># Run the renewal end to end without touching real certificates.
# This exercises the challenge and the hook, which is the point.
certbot renew --dry-run

# Attach the reload to renewal so it only fires when a cert actually changed
certbot renew --deploy-hook "systemctl reload nginx"</code></pre>



<p class="wp-block-paragraph">The distinction that matters: <code>--deploy-hook</code> runs only when a certificate was actually renewed, so you&#8217;re not reloading the server twice a day for nothing. A blanket post-hook reloads regardless.</p>



<p class="wp-block-paragraph">Caddy removes this category of failure. Certificate issuance and renewal are built into the server itself, it swaps the certificate in memory when it renews, and there is no external hook to forget. Point several instances at the same storage backend and they coordinate rather than each hammering the CA. If you have ever been paged for an expired certificate, this alone is a serious argument.</p>



<p class="wp-block-paragraph">It is not free of foot-guns, though. On-demand TLS, where Caddy obtains a certificate during the handshake for a hostname it has never seen, is genuinely useful for multi-tenant setups where customers point their own domains at you. Enabled without restriction, it is an open door: anyone who points a DNS record at your IP can make you request a certificate. Caddy&#8217;s own documentation is blunt that it must be both enabled and restricted, and the mechanism is an <code>ask</code> endpoint you host:</p>



<pre class="wp-block-code"><code>{
    on_demand_tls {
        ask http://localhost:9123/ask
    }
}

https:// {
    tls {
        on_demand
    }
    reverse_proxy 127.0.0.1:3000
}</code></pre>



<p class="wp-block-paragraph">Caddy asks that endpoint whether a hostname is allowed before it will issue. It should be a fast indexed lookup against your own tenant table, returning in milliseconds, because a TLS handshake is being held open while it answers. The older <code>interval</code> and <code>burst</code> rate-limiting options are no longer recommended and should be removed if you still have them in a config.</p>



<p class="wp-block-paragraph">One more Caddy default worth knowing: the admin API listens on localhost port 2019 and accepts a full config replacement without authentication. That is fine bound to loopback and catastrophic if you helpfully expose it while debugging a container.</p>



<h2 class="wp-block-heading">Failure family three: where the configuration lives</h2>



<p class="wp-block-paragraph">This is the axis people underweight, and it&#8217;s usually the one that actually decides the answer.</p>



<p class="wp-block-paragraph">Apache supports <code>.htaccess</code>: per-directory configuration, read at request time, changeable by anything that can write to the directory. No reload, no root access. That is why the entire shared hosting world is built on Apache, and why control panels like cPanel and DirectAdmin, and hosts like InterServer, assume it. A WordPress plugin can rewrite its own rules and it just works.</p>



<p class="wp-block-paragraph">The costs are real and they&#8217;re the mirror image of the benefit. With overrides enabled, Apache walks the directory tree looking for <code>.htaccess</code> files on every single request. And your security rules can be silently overridden by whatever gets deployed into a directory, by anyone who can deploy.</p>



<p class="wp-block-paragraph">Nginx has no equivalent and this is deliberate. Everything is central, and a change requires a reload. Rewrites are handled with <code>try_files</code> rather than per-directory rule files. If you own the box, that&#8217;s a feature: nothing changes routing behind your back and the running configuration is exactly what&#8217;s in version control. If you host other people&#8217;s applications, it&#8217;s a support burden, because every customer rewrite rule becomes a ticket for you.</p>



<p class="wp-block-paragraph">Caddy is central too, either a Caddyfile or JSON. The difference is that config changes can go through the admin API as an atomic swap rather than a file edit plus a signal, which suits automated pipelines nicely.</p>



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



<h2 class="wp-block-heading">Nginx: where it wins and where it doesn&#8217;t</h2>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> high connection counts on modest hardware, static and cached content, TLS termination in front of an application fleet, and the sheer weight of the ecosystem. Every stack you&#8217;ll meet has a known-good Nginx config, and everyone you hire has already read one.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> the DNS behaviour catches people constantly. A hostname written directly into <code>proxy_pass</code>, or into an <code>upstream</code> block, is resolved once at startup and cached for the lifetime of the process. When your upstream is a cloud load balancer or a container that moves, Nginx keeps sending traffic to an IP that stopped being yours. Nothing in the log says &#8220;stale DNS&#8221;; you just get connection timeouts to an address that looks plausible.</p>



<p class="wp-block-paragraph">Forcing re-resolution means putting the name in a variable and giving Nginx a resolver, because that&#8217;s what moves the lookup from config parse time to request time:</p>



<pre class="wp-block-code"><code>location / {
    # resolver moves lookups to runtime; valid= caps how long a result is reused
    resolver 127.0.0.53 valid=30s ipv6=off;

    # the variable is the trick: it defers evaluation to per-request
    set $upstream_host app.internal.example.com;
    proxy_pass http://$upstream_host:8080$request_uri;
}</code></pre>



<p class="wp-block-paragraph">Note the <code>$request_uri</code> on the end. Once a variable appears in <code>proxy_pass</code>, Nginx stops doing its usual URI rewriting for you, so you have to pass the path explicitly. Miss that and every request lands on the upstream&#8217;s root. Also worth knowing: an <code>upstream</code> block defined elsewhere with the same name will take precedence over the runtime lookup, which produces a very confusing afternoon.</p>



<p class="wp-block-paragraph">The other honest downside is governance. Nginx is owned by F5, and a longtime core developer forked it to freenginx after a dispute over how security issues were being handled, with an earlier fork, Angie, already in existence. Mainline Nginx remains overwhelmingly dominant and I would not choose a fork for a client project today. But &#8220;who decides what ships&#8221; is a fair question to ask about any dependency, and this one now has a public answer.</p>



<h2 class="wp-block-heading">Apache: where it wins and where it doesn&#8217;t</h2>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> anywhere applications need to configure the web server without touching the web server. Shared hosting, multi-tenant WordPress, legacy applications that ship their own rewrite rules. The module ecosystem is enormous and modules load at runtime rather than requiring a rebuild. And the documentation is, quietly, the best of the three: nearly every directive has a page explaining its interactions.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> memory per connection, if you&#8217;re still on prefork. Slow clients holding processes open is the specific scenario that hurts, because a process that&#8217;s waiting on a phone on bad mobile data is a process nobody else can use. Putting a CDN or a proxy in front absorbs a lot of that, which is a large part of why Apache origins behind Cloudflare behave so much better than bare ones.</p>



<p class="wp-block-paragraph">I would not migrate a working Apache installation to Nginx purely for performance. I would move it from prefork and <code>mod_php</code> to <code>mpm_event</code> and PHP-FPM first, then measure, and quite often stop there.</p>



<h2 class="wp-block-heading">Caddy: where it wins and where it doesn&#8217;t</h2>



<p class="wp-block-paragraph"><strong>Where it wins:</strong> anything where certificate management is the operational risk. Small teams without a dedicated ops person, internal tooling with a long tail of hostnames nobody tracks, multi-tenant products serving customer-owned domains, container stacks where a single static binary with no runtime dependencies is a real simplification. A working HTTPS reverse proxy is genuinely a few lines.</p>



<pre class="wp-block-code"><code>app.example.com {
    encode zstd gzip
    reverse_proxy app:3000
}</code></pre>



<p class="wp-block-paragraph">That obtains a certificate, redirects HTTP to HTTPS, negotiates compression and proxies, with no ACME client, no cron, no reload hook.</p>



<p class="wp-block-paragraph"><strong>Where it doesn&#8217;t:</strong> the ecosystem gap is real. When something unusual breaks, there are fewer Stack Overflow answers and fewer engineers who have seen it before. Adding a plugin means rebuilding the binary with <code>xcaddy</code> rather than dropping in a module, which is fine in CI and annoying on a box you SSH into. And there&#8217;s a subtler cost: automation you don&#8217;t understand is still automation you&#8217;re responsible for. Being paged about ACME behaviour you&#8217;ve never had to learn is a worse afternoon than being paged about Certbot.</p>



<p class="wp-block-paragraph">On upstream DNS, Caddy dials static upstreams by name so resolution happens at dial time rather than being frozen at config load. When the set of addresses changes, the <code>dynamic a</code> upstream module with a <code>refresh</code> interval is the built-in answer, which is a cleaner story than Nginx&#8217;s variable trick.</p>



<h2 class="wp-block-heading">A decision procedure for Nginx vs Apache vs Caddy</h2>



<p class="wp-block-paragraph">Work down this list and stop at the first one that matches. It&#8217;ll be right more often than a benchmark.</p>



<ol class="wp-block-list">
<li><strong>Do applications you don&#8217;t control need to change server behaviour?</strong> If yes, Apache. Nothing else has <code>.htaccess</code>, and reimplementing it in tickets is not a plan.</li>

<li><strong>Is TLS renewal your top operational risk?</strong> Small team, no dedicated ops, lots of hostnames, or customer-owned domains: Caddy. It deletes a whole class of incident.</li>

<li><strong>Are you terminating a very high connection rate, or serving heavy static content?</strong> Nginx. This is the workload it was designed around.</li>

<li><strong>Does the team already run one of these well?</strong> Keep it. Operational familiarity beats a 20% throughput difference you were never going to measure.</li>

<li><strong>Is there a working Apache install and no specific complaint?</strong> Fix the MPM and PHP handler, then reassess. Most &#8220;we need Nginx&#8221; tickets are actually &#8220;we need to stop using prefork.&#8221;</li>

<li><strong>None of the above?</strong> Nginx for anything that will be handed to another engineer. Caddy for anything you&#8217;ll run yourself.</li>
</ol>



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



<ul class="wp-block-list">
<li><strong>Renewing certificates without reloading.</strong> The single most common self-inflicted TLS outage on Nginx and Apache. Use a deploy hook and verify it with a dry run.</li>

<li><strong>Migrating for benchmark numbers.</strong> You inherit a new failure mode and a team that has never debugged it, in exchange for latency nobody could perceive.</li>

<li><strong>Leaving Apache on prefork after moving to PHP-FPM.</strong> The migration is only half done, and you&#8217;re still paying prefork&#8217;s memory cost while also giving up HTTP/2.</li>

<li><strong>Hostnames baked into <code>proxy_pass</code> with dynamic upstreams.</strong> Works perfectly until the upstream IP changes, then fails in a way that looks like a network problem.</li>

<li><strong>Enabling Caddy&#8217;s on-demand TLS without an <code>ask</code> endpoint.</strong> You&#8217;ve handed strangers the ability to make you request certificates.</li>

<li><strong>Exposing Caddy&#8217;s admin API beyond loopback.</strong> Unauthenticated full config replacement is exactly as bad as it sounds.</li>

<li><strong>Monitoring the certificate file instead of the handshake.</strong> The file is almost never the thing that&#8217;s wrong. Check what the server actually presents, from outside the box.</li>
</ul>



<h2 class="wp-block-heading">How I&#8217;d decide, in practice</h2>



<ul class="wp-block-list">
<li>Pick based on who has to change the config six months from now, not on requests per second today.</li>

<li>Whichever you choose, dump the effective configuration rather than reading files: <code>nginx -T</code>, <code>apachectl -S</code>, <code>caddy adapt --config /etc/caddy/Caddyfile</code>. Include files hide surprises.</li>

<li>Monitor TLS expiry as an external handshake check from a service like Better Stack or a Grafana Cloud synthetic, not as a filesystem check on the box.</li>

<li>Put something in front. A CDN like Cloudflare or Fastly absorbs slow clients and connection floods, which flattens most of the architectural differences here.</li>

<li>Run one server type per environment. Mixing Nginx in staging and Apache in production means your rewrite rules are untested by definition.</li>

<li>Validate before reloading, every time. <code>nginx -t</code>, <code>apachectl configtest</code> and <code>caddy validate</code> all exist so you don&#8217;t find out from users.</li>
</ul>



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



<h3 class="wp-block-heading">Is Caddy production ready?</h3>



<p class="wp-block-paragraph">Yes. It&#8217;s been running real workloads for years and its certificate automation removes more operational risk than its smaller ecosystem adds, for most teams. The caveat is support surface, not stability: when you hit something unusual, there are fewer people who have hit it before you.</p>



<h3 class="wp-block-heading">Can I run Nginx and Apache together?</h3>



<p class="wp-block-paragraph">Yes, and it&#8217;s a common pattern: Nginx on 80 and 443 handling TLS and static files, proxying dynamic requests to Apache on a local port so applications keep their <code>.htaccess</code> behaviour. You get Nginx&#8217;s connection handling and Apache&#8217;s per-directory config. The cost is two servers to patch, two logs to correlate, and real client IP to forward correctly.</p>



<h3 class="wp-block-heading">Which is best for WordPress?</h3>



<p class="wp-block-paragraph">Apache if plugins need to write rewrite rules, which on a typical multi-plugin site they do. Nginx if you control the site and are willing to translate those rules into <code>try_files</code> and static location blocks once. Plenty of high-traffic WordPress runs on Nginx; it just means plugin-generated rules are your job now.</p>



<h3 class="wp-block-heading">Does Nginx really re-resolve DNS for upstreams?</h3>



<p class="wp-block-paragraph">Not by default. A literal hostname in <code>proxy_pass</code> or an <code>upstream</code> block is resolved at startup and reused indefinitely, regardless of TTL. You get runtime resolution by using a variable together with a <code>resolver</code> directive, and remembering that this changes how the request URI is passed.</p>



<h3 class="wp-block-heading">Should I switch from mod_php to PHP-FPM?</h3>



<p class="wp-block-paragraph">In almost all cases yes. It&#8217;s the change that unlocks Apache&#8217;s threaded MPMs and HTTP/2, and it separates PHP&#8217;s lifecycle from the web server&#8217;s so you can restart one without the other. Check first for anything relying on PHP settings in Apache config or <code>.htaccess</code>, since those move to a <code>.user.ini</code> file with different syntax.</p>



<h3 class="wp-block-heading">Does the freenginx fork mean I should stop using Nginx?</h3>



<p class="wp-block-paragraph">No. Mainline Nginx under F5 remains the overwhelmingly dominant option with the broadest packaging and support. The fork is worth knowing about as context on project governance rather than as a migration target, and I wouldn&#8217;t put a client on a fork without a specific reason.</p>



<h3 class="wp-block-heading">Which uses the least memory?</h3>



<p class="wp-block-paragraph">Nginx, generally, with Caddy close behind and Apache furthest away when running prefork. But the gap narrows sharply once Apache is on <code>mpm_event</code>, and on a VPS from a provider like Hetzner or DigitalOcean the memory your application uses will dwarf all three. Measure your own stack before treating this as a deciding factor.</p>



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



<p class="wp-block-paragraph">The <strong>Nginx vs Apache vs Caddy</strong> question is not a performance question. All three are fast enough for what you&#8217;re doing. What differs is which operational chores each one hands back to you: Apache gives applications control at the cost of memory and predictability, Nginx gives you central control at the cost of owning every rewrite rule and the certificate reload, and Caddy takes the certificate lifecycle off your plate at the cost of a smaller ecosystem when things get strange.</p>



<p class="wp-block-paragraph">Pick the set of chores your team can actually carry. Then go fix the thing that&#8217;s genuinely slow, which is almost certainly not the web server.</p>



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



<h2 class="wp-block-heading">Need a second opinion on your web server setup?</h2>



<p class="wp-block-paragraph">Most of the work I do here is unglamorous and pays for itself quickly. Things I can help with:</p>



<ul class="wp-block-list">
<li>Auditing an existing Nginx, Apache or Caddy config and telling you what will break under load before it does</li>

<li>Migrating Apache from prefork and <code>mod_php</code> to <code>mpm_event</code> with PHP-FPM, without breaking existing <code>.htaccess</code> behaviour</li>

<li>Translating a working Apache rewrite set into an Nginx configuration, with a test plan so you know it matches</li>

<li>Fixing certificate renewal that runs but never reloads, including deploy hooks and external expiry monitoring</li>

<li>Setting up Caddy for multi-tenant or customer-domain hosting with on-demand TLS properly restricted</li>

<li>Diagnosing intermittent 502s, 504s and stale-upstream timeouts that look like network faults and aren&#8217;t</li>
</ul>



<p class="wp-block-paragraph">Send me your config, an error log excerpt, or the output of <code>nginx -T</code> and tell me what you&#8217;re seeing. I&#8217;d rather look at the real thing than guess from a description.</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/nginx-vs-apache-vs-caddy/">Nginx vs Apache vs Caddy: What Actually Decides It in Real Projects</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>It Works in My Browser: Debugging TLS Certificate Errors in Chains, SANs and Expiry</title>
		<link>https://john-nessime.com/blog/system-administration/debugging-tls-certificate-errors/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:23 +0000</pubDate>
				<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[ACME]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Certbot]]></category>
		<category><![CDATA[Certificate Chain]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OpenSSL]]></category>
		<category><![CDATA[SSL Certificate]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[TLS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=64</guid>

					<description><![CDATA[<p>Your browser shows a padlock and your partner's API client throws "unable to get local issuer certificate". Both are correct. A practical guide to the four things a TLS client actually checks, and to renewal automation that reaches the running server instead of just the disk.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/debugging-tls-certificate-errors/">It Works in My Browser: Debugging TLS Certificate Errors in Chains, SANs and Expiry</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">A partner emails to say your API has been rejecting their integration for two days. Their client throws <code>unable to get local issuer certificate</code>. You open the URL in Chrome, see a padlock, and reply that it works fine on your end. Then someone runs <code>curl</code> from a clean container and it fails there too.</p>



<p class="wp-block-paragraph">Your server is only sending the leaf certificate. The intermediate that links it to a trusted root is missing. Your browser papers over that: it either has the intermediate cached from another site, or it quietly fetches it using the AIA extension in the certificate. <code>curl</code>, Java, Go, Python and every other non-browser client do no such thing. They see a certificate signed by something they do not trust and stop.</p>



<p class="wp-block-paragraph">This is the most common of all <strong>TLS certificate errors</strong> and it is close to invisible from a desktop. The padlock in your browser is not a test. It is a client with a large safety net doing you a favour.</p>



<p class="wp-block-paragraph">This post covers the four things a client actually checks and how each one fails: the chain, the names, the clock, and the trust store. Then the automation half, which has its own quiet failure: a renewal that succeeds and never reaches the running server. Plus the two things about TLS that have genuinely changed recently and will break config you copied from an older guide.</p>



<h2 class="wp-block-heading">Look at what the server is actually sending</h2>



<p class="wp-block-paragraph">Before theorising, get the facts. One command tells you almost everything.</p>



<pre class="wp-block-code"><code># -servername sends SNI. Without it you get whatever the default
# vhost serves, which is a different certificate on most servers
# and the source of a lot of confusing results.
# -showcerts prints every certificate the server sent, in order.
openssl s_client -connect example.com:443 -servername example.com -showcerts &lt;/dev/null</code></pre>



<p class="wp-block-paragraph">Read the top of that output. There is a <code>Certificate chain</code> block listing what arrived, numbered from zero, with <code>s:</code> for subject and <code>i:</code> for issuer. Entry 0 is your certificate. Entry 1 should be the intermediate that issued it, and its subject should exactly match entry 0&#8217;s issuer.</p>



<p class="wp-block-paragraph">If the chain block has only entry 0, that is your bug. Right at the bottom, <code>Verify return code</code> gives the verdict: <code>0 (ok)</code> is what you want, <code>21 (unable to verify the first certificate)</code> means the chain is incomplete, and <code>20 (unable to get local issuer certificate)</code> means the issuer is not in the local trust store.</p>



<p class="wp-block-paragraph">Run it from a machine that is not yours. A clean container is ideal, because your laptop has accumulated intermediates and corporate roots that a customer&#8217;s server has not.</p>



<h2 class="wp-block-heading">Chain problems</h2>



<p class="wp-block-paragraph">Almost all of these come down to one file choice. ACME clients write out several files and two of them look interchangeable:</p>



<ul class="wp-block-list">
<li><strong><code>cert.pem</code></strong> is the leaf alone. Using this is the mistake.</li>
<li><strong><code>chain.pem</code></strong> is the intermediates alone.</li>
<li><strong><code>fullchain.pem</code></strong> is leaf plus intermediates, in the right order. This is the one your server should point at.</li>
</ul>



<pre class="wp-block-code"><code># nginx
ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;</code></pre>



<pre class="wp-block-code"><code># Apache. On current versions the chain goes in SSLCertificateFile.
# SSLCertificateChainFile is deprecated; guides that still use it
# are old enough that other advice in them is probably stale too.
SSLCertificateFile      /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile   /etc/letsencrypt/live/example.com/privkey.pem</code></pre>



<p class="wp-block-paragraph">Order matters as well as contents. The leaf must come first, then each intermediate that signs the one above it. A chain assembled in the wrong order will be rejected by strict clients even though every certificate in it is correct. You can check a chain offline before deploying it:</p>



<pre class="wp-block-code"><code># Does this leaf chain to a trusted root using these intermediates?
# Exits non-zero and explains itself if not.
openssl verify -untrusted chain.pem cert.pem</code></pre>



<p class="wp-block-paragraph">Do not include the root certificate in the file you serve. The client either has it or does not, and sending it wastes bytes on every handshake without changing any outcome.</p>



<h2 class="wp-block-heading">Name problems</h2>



<p class="wp-block-paragraph">The second family. A certificate can be perfectly valid and still be the wrong certificate for the name being requested.</p>



<pre class="wp-block-code"><code># The hostname the client asked for must appear in here.
openssl x509 -in cert.pem -noout -text | grep -A1 "Subject Alternative Name"</code></pre>



<p class="wp-block-paragraph">Three things people get wrong here:</p>



<ul class="wp-block-list">
<li><strong>The Common Name does not count.</strong> Modern clients ignore the CN field entirely for hostname matching. If the name is only in the CN and not in the SAN list, it will fail, and the error message will not explain that clearly.</li>
<li><strong>Wildcards are exactly one label deep.</strong> <code>*.example.com</code> covers <code>www.example.com</code> and <code>api.example.com</code>. It does not cover <code>example.com</code> itself, and it does not cover <code>a.b.example.com</code>. The bare domain has to be listed separately, and it usually is not.</li>
<li><strong>SNI decides which certificate you get.</strong> One IP serving many sites picks the certificate based on the name the client sends during the handshake. A client that does not send SNI, or an <code>openssl</code> command without <code>-servername</code>, gets the default vhost&#8217;s certificate and produces a name mismatch that has nothing to do with your actual configuration.</li>
</ul>



<h2 class="wp-block-heading">Time and trust problems</h2>



<pre class="wp-block-code"><code># Local file
openssl x509 -in cert.pem -noout -dates

# Live endpoint, subject and dates in one go
openssl s_client -connect example.com:443 -servername example.com &lt;/dev/null 2&gt;/dev/null 
  | openssl x509 -noout -dates -subject</code></pre>



<p class="wp-block-paragraph">Expired is obvious. <code>notBefore</code> in the future is the interesting one, because it almost never means the certificate is wrong. It means the client&#8217;s clock is wrong. Containers with no NTP, a VM restored from a snapshot, an embedded device with a dead battery: all of them produce &#8220;certificate not yet valid&#8221; against a perfectly good certificate. Check the clock on the failing machine before you touch anything on the server.</p>



<p class="wp-block-paragraph">Trust store problems look similar but come from a different place. The root that signed your chain has to be in the trust store of the machine making the request, and old systems have old trust stores. A device that has not received an OS update in years may simply not know about a certificate authority that appeared since. There is no server-side fix for that beyond choosing a CA with broader legacy coverage, which is one of the few genuine arguments for a paid certificate.</p>



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



<h2 class="wp-block-heading">The renewal that succeeded and changed nothing</h2>



<p class="wp-block-paragraph">Here is the second invisible failure, and it catches experienced people. Your renewal timer runs, the ACME client obtains a new certificate, writes it to disk, logs success. The site goes down anyway.</p>



<p class="wp-block-paragraph">Nginx read the certificate into memory when it started. It does not watch the file. Neither does Apache, HAProxy, Postfix, or almost anything else. Until the service is reloaded it keeps serving the certificate it loaded at boot, which is the one that just expired. Everything in your logs says the renewal worked, because it did. The delivery step was never wired up.</p>



<p class="wp-block-paragraph">Certbot handles this with deploy hooks, which run only when a certificate was actually renewed. Put a script in the hooks directory and it applies to every certificate on the box:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-services.sh
# Remember: chmod +x, or it silently never runs.
set -euo pipefail

# Validate the config BEFORE reloading. A reload with a broken
# config can take the site down at the exact moment you were
# trying to keep it up.
nginx -t
systemctl reload nginx

# Anything else that holds a copy of the certificate.
# systemctl reload postfix</code></pre>



<p class="wp-block-paragraph">Then prove the whole path works without waiting sixty days to find out:</p>



<pre class="wp-block-code"><code># Exercises the full renewal against the staging environment.
# Installs nothing, counts against no rate limits, and will
# fail loudly if your challenge setup is broken.
certbot renew --dry-run</code></pre>



<p class="wp-block-paragraph">One caveat worth knowing: <code>--dry-run</code> does not run deploy hooks by default, precisely because nothing was deployed. So it validates the issuance path but not the reload. Test that separately by touching the service and confirming the served certificate changes.</p>



<h2 class="wp-block-heading">Expiry automation, and why it is no longer optional</h2>



<p class="wp-block-paragraph">This part has a date attached, so it is worth stating plainly rather than dancing around it.</p>



<p class="wp-block-paragraph">The CA/Browser Forum approved a phased reduction of maximum certificate lifetimes. The cap dropped from 398 days to 200 days on 15 March 2026, falls to 100 days on 15 March 2027, and reaches 47 days on 15 March 2029. Domain validation reuse periods shrink alongside it.</p>



<p class="wp-block-paragraph">The practical consequence: any process that involves a person remembering to renew something once a year is already broken, and by the third phase you are renewing every six weeks. Whatever you were getting away with manually, stop. ACME with a timer is the baseline. Certbot is the default choice, acme.sh is lighter and easier to script around, and Caddy obtains and renews certificates with no configuration at all, which is a genuinely good reason to consider it for a simple reverse proxy.</p>



<h3 class="wp-block-heading">Choosing a challenge type</h3>



<ul class="wp-block-list">
<li><strong>HTTP-01</strong> proves control by serving a file over port 80. Simple, and it breaks whenever port 80 is closed, redirected wholesale to HTTPS in a way that mangles the challenge path, or sitting behind a proxy that does not forward it.</li>
<li><strong>DNS-01</strong> proves control by writing a TXT record. More setup, needs an API token for your DNS provider, and it is the only option for wildcard certificates. It also works for hosts with no inbound internet access at all, which is why I reach for it on anything internal.</li>
</ul>



<p class="wp-block-paragraph">The trade-off with DNS-01 is a credential: your renewal process now holds a token that can edit your DNS zone. Scope it to the narrowest permission your provider offers, and if they support delegating just the <code>_acme-challenge</code> records to a separate zone, do that instead.</p>



<h3 class="wp-block-heading">Monitor expiry externally</h3>



<p class="wp-block-paragraph">Do not rely on the renewal system reporting its own health. If it is broken, it is also probably not telling you. Let&#8217;s Encrypt has discontinued its expiration reminder emails, so if that was quietly acting as your safety net, it is not one any more.</p>



<p class="wp-block-paragraph">Check from outside, against the live endpoint, the way a user would:</p>



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

HOST="example.com"
DAYS=21

# -checkend takes a number of seconds and exits non-zero if the
# certificate expires within that window. No parsing dates by hand.
if ! openssl s_client -connect "$HOST:443" -servername "$HOST" &lt;/dev/null 2&gt;/dev/null 
     | openssl x509 -noout -checkend $(( DAYS * 86400 )); then
  echo "TLS certificate for $HOST expires within $DAYS days" &gt;&amp;2
  exit 1
fi</code></pre>



<p class="wp-block-paragraph">If you already run Prometheus, the blackbox exporter gives you <code>probe_ssl_earliest_cert_expiry</code> for free, which is a timestamp you can alert on and graph in Grafana across every endpoint you own. That is the better answer at more than a handful of hosts, because it also catches the certificates nobody remembered existed.</p>



<h2 class="wp-block-heading">Two pieces of config that are now wrong</h2>



<p class="wp-block-paragraph"><strong>OCSP stapling with Let&#8217;s Encrypt.</strong> Let&#8217;s Encrypt removed the OCSP responder URL from newly issued certificates in May 2025 and shut down its responders in August 2025. If your config still has this, it does nothing except print a warning on every reload:</p>



<pre class="wp-block-code"><code># nginx: [warn] "ssl_stapling" ignored, no OCSP responder URL in the certificate
# Comment these out if your certificates come from Let's Encrypt.
# ssl_stapling on;
# ssl_stapling_verify on;</code></pre>



<p class="wp-block-paragraph">Nothing breaks by leaving them, but a warning that appears on every reload is a warning people learn to ignore, and then they ignore the next one too.</p>



<p class="wp-block-paragraph"><strong>Hand-tuned cipher suite lists.</strong> If you pasted a <code>ssl_ciphers</code> line from a guide more than a couple of years old, re-check it against the current Mozilla SSL Configuration Generator. Lists that were considered hardened at the time now sometimes exclude things you want or include things you do not, and the defaults have improved considerably.</p>



<h2 class="wp-block-heading">When there is a CDN in front</h2>



<p class="wp-block-paragraph">Behind Cloudflare or a similar edge, there are two separate TLS connections: browser to edge, and edge to your origin. They use different certificates and fail independently.</p>



<p class="wp-block-paragraph">This creates a specific blind spot. Your origin certificate can be expired, self-signed or serving the wrong name, and visitors will see nothing wrong, because the edge presents its own valid certificate and, in the more permissive encryption modes, does not verify the origin. It works until you switch to strict origin validation, or until the edge is bypassed, and then everything fails at once for reasons that look unrelated to anything you changed.</p>



<p class="wp-block-paragraph">Test the origin directly by name, bypassing DNS. If your origin is a VPS with a provider like InterServer and the public DNS points at the CDN, this is the only way to see what the origin is really serving:</p>



<pre class="wp-block-code"><code># --resolve forces the hostname to a specific IP without touching
# /etc/hosts, so SNI and Host header stay correct.
curl -v --resolve example.com:443:203.0.113.10 https://example.com/</code></pre>



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



<h3 class="wp-block-heading">unable to get local issuer certificate</h3>



<p class="wp-block-paragraph">Missing intermediate on the server, or a trust store on the client that lacks the root. Check the server first with <code>-showcerts</code>; if the chain is complete, the problem is on the client side.</p>



<h3 class="wp-block-heading">certificate has expired, but I just renewed it</h3>



<p class="wp-block-paragraph">The service was never reloaded. Compare the dates on disk with the dates being served. If they differ, that is your answer, and the fix is a deploy hook rather than another manual renewal.</p>



<h3 class="wp-block-heading">Hostname mismatch on a name that is definitely in the certificate</h3>



<p class="wp-block-paragraph">Either SNI is not reaching the right vhost, or the name is in the Common Name rather than the SAN list, or you are hitting a wildcard at the wrong depth. Check the SAN list and re-run <code>s_client</code> with an explicit <code>-servername</code>.</p>



<h3 class="wp-block-heading">Works over IPv4, fails over IPv6 (or the reverse)</h3>



<p class="wp-block-paragraph">Two different machines are answering, and only one of them was updated. Force the family with <code>curl -4</code> and <code>curl -6</code> and compare. Load balancers with one member out of sync produce the same intermittent pattern.</p>



<h3 class="wp-block-heading">Renewal fails with a challenge error</h3>



<p class="wp-block-paragraph">For HTTP-01, confirm that <code>http://example.com/.well-known/acme-challenge/test</code> is reachable from outside without being redirected or rewritten. A blanket redirect to HTTPS is the usual culprit. For DNS-01, check that the TXT record actually appears with <code>dig</code>, and give slow-propagating providers more time.</p>



<h3 class="wp-block-heading">Everything looks right and it still fails</h3>



<p class="wp-block-paragraph">Run an external scan. SSL Labs or a local run of <code>testssl.sh</code> will check things you did not think to, including protocol support and chain issues from the perspective of clients you do not have installed.</p>



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



<ul class="wp-block-list">
<li>Pointing the server at <code>cert.pem</code> instead of <code>fullchain.pem</code>.</li>
<li>Treating a padlock in your own browser as proof the certificate is correctly deployed.</li>
<li>Testing with <code>openssl s_client</code> without <code>-servername</code> and debugging the wrong certificate.</li>
<li>Assuming <code>*.example.com</code> covers the bare domain.</li>
<li>Putting the hostname only in the Common Name.</li>
<li>Renewing without reloading the service.</li>
<li>Forgetting <code>chmod +x</code> on a deploy hook, so it silently never runs.</li>
<li>Relying on the renewal system to tell you when it is broken.</li>
<li>Including the root certificate in the served chain.</li>
<li>Leaving OCSP stapling enabled with certificates that no longer support it.</li>
<li>Ignoring the origin certificate because the CDN hides its state.</li>
<li>Debugging a &#8220;not yet valid&#8221; certificate on the server when the client&#8217;s clock is wrong.</li>
</ul>



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



<ul class="wp-block-list">
<li>Serve the full chain, leaf first, without the root.</li>
<li>Verify from a clean machine or container, never from the laptop you browse on.</li>
<li>Put every name you serve in the SAN list, including the bare domain alongside any wildcard.</li>
<li>Automate renewal with ACME and a timer, and run <code>certbot renew --dry-run</code> after any change to the setup.</li>
<li>Attach a deploy hook that validates the config before reloading, and make it executable.</li>
<li>Monitor expiry externally against the live endpoint, and alert with weeks of headroom.</li>
<li>Use DNS-01 for wildcards and internal hosts, with a narrowly scoped API token.</li>
<li>Keep NTP running everywhere, including inside containers.</li>
<li>Test the origin directly when there is a CDN in front, not just the public hostname.</li>
<li>Inventory your certificates, because the one that takes you down is the one nobody remembered.</li>
</ul>



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



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



<h3 class="wp-block-heading">Why does my certificate work in Chrome but fail in curl?</h3>



<p class="wp-block-paragraph">Your server is not sending the intermediate certificate. Browsers work around this by caching intermediates from other sites and by fetching them from the URL in the certificate&#8217;s AIA extension. Command-line tools and application HTTP libraries do neither. Switch the server to <code>fullchain.pem</code> and reload.</p>



<h3 class="wp-block-heading">What is the difference between cert.pem, chain.pem and fullchain.pem?</h3>



<p class="wp-block-paragraph">Leaf only, intermediates only, and both concatenated in the correct order. Web servers want <code>fullchain.pem</code>. The private key stays in <code>privkey.pem</code> and never goes in any of them.</p>



<h3 class="wp-block-heading">Does a wildcard certificate cover the root domain?</h3>



<p class="wp-block-paragraph">No. <code>*.example.com</code> matches exactly one label, so it covers <code>www.example.com</code> but not <code>example.com</code> and not <code>a.b.example.com</code>. Request the bare domain as an additional SAN, which most ACME clients will do if you pass both names.</p>



<h3 class="wp-block-heading">How often should certificates renew now?</h3>



<p class="wp-block-paragraph">Whatever your CA issues, renew at roughly two-thirds of the lifetime so a failed attempt has room to retry before anything expires. Since maximum lifetimes are on a published downward schedule, build for automated renewal at any interval rather than tuning a number you will have to change again.</p>



<h3 class="wp-block-heading">Should I still enable OCSP stapling?</h3>



<p class="wp-block-paragraph">Not with Let&#8217;s Encrypt certificates, which no longer carry a responder URL. If your CA still supports it, stapling remains worthwhile. Check whether your certificate actually contains an OCSP URL before enabling the directives, rather than assuming.</p>



<h3 class="wp-block-heading">Is a paid certificate more secure than a free one?</h3>



<p class="wp-block-paragraph">Cryptographically, no. The encryption is identical. What money buys is broader compatibility with old trust stores, longer validation reuse, warranties, and human support when something goes wrong. For a normal public website, a free automated certificate is the right default.</p>



<h3 class="wp-block-heading">How do I get a certificate for something with no public DNS or open ports?</h3>



<p class="wp-block-paragraph">DNS-01, on a public domain you control, pointed at whatever internal address you like. The CA only needs to see the TXT record; it never needs to reach the host. This is how internal services get publicly trusted certificates without being exposed.</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">Most TLS certificate errors are not about cryptography. They are about delivery: whether the server sends everything the client needs, whether the name it sends matches the name that was asked for, and whether the certificate sitting in memory is the same one sitting on disk.</p>



<p class="wp-block-paragraph">So stop checking with your browser. Check with <code>openssl s_client</code> from a machine that owes you no favours, and make sure something outside your server is watching the expiry date. As lifetimes keep shrinking, the gap between a renewal process that works and one that merely appears to work gets narrower every year.</p>



<h2 class="wp-block-heading">Certificates causing you trouble?</h2>



<p class="wp-block-paragraph">Certificate problems tend to arrive at bad moments and turn out to be one wrong file path. Things I help with:</p>



<ul class="wp-block-list">
<li>Diagnosing a certificate that works for some clients and not others, and telling you exactly which link in the chain is missing.</li>
<li>Setting up ACME renewal with deploy hooks across nginx, Apache, HAProxy, mail servers and anything else holding a copy.</li>
<li>Migrating to DNS-01 for wildcards and internal hosts, with properly scoped API credentials.</li>
<li>Certificate inventory and expiry monitoring in Prometheus and Grafana, so nothing expires without warning.</li>
<li>Preparing a certificate estate for shorter lifetimes, replacing anything that still depends on a person remembering.</li>
<li>Origin and edge TLS configuration behind a CDN, including strict origin validation.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>openssl s_client -connect yourhost:443 -servername yourhost -showcerts</code> and the error your failing client reports, and I will tell you what is 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/system-administration/debugging-tls-certificate-errors/">It Works in My Browser: Debugging TLS Certificate Errors in Chains, SANs and Expiry</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>
		<item>
		<title>Prometheus + Grafana From Scratch on One Server</title>
		<link>https://john-nessime.com/blog/devops/prometheus-grafana-one-server/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 15:23:39 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=39</guid>

					<description><![CDATA[<p>Two static binaries, a package and four systemd units. How to install Prometheus and Grafana on a single server from scratch, wire in node_exporter, put nginx and TLS in front, and keep the ports that have no authentication off the public internet.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/prometheus-grafana-one-server/">Prometheus + Grafana From Scratch on One Server</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 want to know when the disk fills up before a customer tells you. Search for how to do that and you get pointed at a hosted platform with per-host pricing, or a twelve-service Docker Compose file that somebody copy-pasted from a blog and does not fully understand either.</p>



<p class="wp-block-paragraph">For a single server, neither is the right answer. Prometheus and Grafana are two static Go binaries and a package. They run happily under systemd, use very little memory at this scale, and when something breaks you can read the logs and actually fix it. No container networking to debug, no orchestration layer between you and the problem.</p>



<p class="wp-block-paragraph">This walks through the whole thing: install Prometheus and Grafana from scratch, wire in node_exporter, put the lot behind nginx with TLS, and lock down the ports that should never have been public in the first place. Commands work on both Debian-family and RHEL-family systems, with the differences called out.</p>



<h2 class="wp-block-heading">What you are building</h2>



<p class="wp-block-paragraph">Four moving parts, all on one box:</p>



<ul class="wp-block-list">
<li><strong>node_exporter</strong> exposes machine metrics — CPU, memory, disk, network — on port 9100.</li>

<li><strong>Prometheus</strong> scrapes that endpoint on a schedule and stores the samples in its own time series database on port 9090.</li>

<li><strong>Grafana</strong> queries Prometheus and draws the dashboards on port 3000.</li>

<li><strong>nginx</strong> terminates TLS and is the only thing listening on a public interface.</li>
</ul>



<p class="wp-block-paragraph">The critical design decision is in that last line. Prometheus and node_exporter ship with no authentication at all. Bound to a public interface, they hand your entire infrastructure inventory to anyone who runs a port scan. Everything binds to 127.0.0.1 and nginx is the single front door.</p>



<p class="wp-block-paragraph">Resource-wise, a 2 GB VPS handles this comfortably for one server&#8217;s worth of metrics. Prometheus is the memory-hungry one, and its appetite scales with the number of active time series rather than the number of hosts.</p>



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



<h2 class="wp-block-heading">Step 1: Users and directories</h2>



<p class="wp-block-paragraph">Neither service should run as root, and neither needs a login shell.</p>



<pre class="wp-block-code"><code>sudo useradd --system --no-create-home --shell /sbin/nologin prometheus
sudo useradd --system --no-create-home --shell /sbin/nologin node_exporter

sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo chown prometheus:prometheus /etc/prometheus /var/lib/prometheus</code></pre>



<p class="wp-block-paragraph">On Debian and Ubuntu the nologin shell lives at <code>/usr/sbin/nologin</code> rather than <code>/sbin/nologin</code>. Check with <code>which nologin</code> if useradd complains.</p>



<h2 class="wp-block-heading">Step 2: Install Prometheus</h2>



<p class="wp-block-paragraph">Prometheus ships as a single static binary. Rather than hardcoding a version that will be stale next month, pull the current release tag from the API:</p>



<pre class="wp-block-code"><code>PROM_VERSION=$(curl -s https://api.github.com/repos/prometheus/prometheus/releases/latest 
  | grep -Po '"tag_name": "vK[^"]*')
echo "Installing Prometheus ${PROM_VERSION}"

cd /tmp
curl -LO "https://github.com/prometheus/prometheus/releases/download/v${PROM_VERSION}/prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
tar xzf "prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
cd "prometheus-${PROM_VERSION}.linux-amd64"

sudo cp prometheus promtool /usr/local/bin/
sudo chown prometheus:prometheus /usr/local/bin/prometheus /usr/local/bin/promtool</code></pre>



<p class="wp-block-paragraph">If you are on ARM, swap <code>linux-amd64</code> for <code>linux-arm64</code>.</p>



<h3 class="wp-block-heading">The configuration file</h3>



<p class="wp-block-paragraph">Write <code>/etc/prometheus/prometheus.yml</code>:</p>



<pre class="wp-block-code"><code>global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ["127.0.0.1:9090"]

  - job_name: node
    static_configs:
      - targets: ["127.0.0.1:9100"]
        labels:
          instance: "web-01"</code></pre>



<p class="wp-block-paragraph">Set that <code>instance</code> label deliberately. It follows every metric into every dashboard and alert, and renaming it later means your historical graphs split in two.</p>



<p class="wp-block-paragraph">Validate before you start anything:</p>



<pre class="wp-block-code"><code>promtool check config /etc/prometheus/prometheus.yml</code></pre>



<h3 class="wp-block-heading">The systemd unit</h3>



<p class="wp-block-paragraph">Write <code>/etc/systemd/system/prometheus.service</code>:</p>



<pre class="wp-block-code"><code>[Unit]
Description=Prometheus
Wants=network-online.target
After=network-online.target

[Service]
User=prometheus
Group=prometheus
Type=simple
Restart=on-failure
RestartSec=5
ExecStart=/usr/local/bin/prometheus 
  --config.file=/etc/prometheus/prometheus.yml 
  --storage.tsdb.path=/var/lib/prometheus 
  --storage.tsdb.retention.time=30d 
  --storage.tsdb.retention.size=8GB 
  --web.listen-address=127.0.0.1:9090
ExecReload=/bin/kill -HUP $MAINPID

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/prometheus
PrivateTmp=true

[Install]
WantedBy=multi-user.target</code></pre>



<p class="wp-block-paragraph">Two things worth noticing. <code>--web.listen-address=127.0.0.1:9090</code> is what keeps Prometheus off the public internet. And setting <em>both</em> retention flags matters — time-based retention alone will not save you if metric volume spikes, because Prometheus honours whichever limit it hits first.</p>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now prometheus
sudo systemctl status prometheus</code></pre>



<h2 class="wp-block-heading">Step 3: node_exporter</h2>



<p class="wp-block-paragraph">Same pattern — a static binary and a unit file.</p>



<pre class="wp-block-code"><code>NE_VERSION=$(curl -s https://api.github.com/repos/prometheus/node_exporter/releases/latest 
  | grep -Po '"tag_name": "vK[^"]*')

cd /tmp
curl -LO "https://github.com/prometheus/node_exporter/releases/download/v${NE_VERSION}/node_exporter-${NE_VERSION}.linux-amd64.tar.gz"
tar xzf "node_exporter-${NE_VERSION}.linux-amd64.tar.gz"
sudo cp "node_exporter-${NE_VERSION}.linux-amd64/node_exporter" /usr/local/bin/
sudo chown node_exporter:node_exporter /usr/local/bin/node_exporter</code></pre>



<p class="wp-block-paragraph">Then <code>/etc/systemd/system/node_exporter.service</code>:</p>



<pre class="wp-block-code"><code>[Unit]
Description=Prometheus Node Exporter
Wants=network-online.target
After=network-online.target

[Service]
User=node_exporter
Group=node_exporter
Type=simple
Restart=on-failure
ExecStart=/usr/local/bin/node_exporter 
  --web.listen-address=127.0.0.1:9100

NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target</code></pre>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now node_exporter

# Confirm it is actually producing metrics
curl -s http://127.0.0.1:9100/metrics | head -20</code></pre>



<p class="wp-block-paragraph">Now open <code>http://127.0.0.1:9090/targets</code> — via an SSH tunnel, since it is not public — and both jobs should read UP.</p>



<pre class="wp-block-code"><code># From your laptop
ssh -L 9090:127.0.0.1:9090 you@your-server</code></pre>



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



<h2 class="wp-block-heading">Step 4: Install Grafana</h2>



<p class="wp-block-paragraph">Grafana is packaged properly, so use the official repository rather than a tarball. It gets you signed updates through your normal patching process.</p>



<p class="wp-block-paragraph"><strong>Debian and Ubuntu:</strong></p>



<pre class="wp-block-code"><code>sudo apt-get install -y apt-transport-https software-properties-common wget
sudo mkdir -p /etc/apt/keyrings/
wget -q -O - https://apt.grafana.com/gpg.key 
  | gpg --dearmor | sudo tee /etc/apt/keyrings/grafana.gpg &gt; /dev/null

echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" 
  | sudo tee /etc/apt/sources.list.d/grafana.list

sudo apt-get update
sudo apt-get install -y grafana</code></pre>



<p class="wp-block-paragraph"><strong>RHEL, AlmaLinux, Rocky:</strong></p>



<pre class="wp-block-code"><code>sudo tee /etc/yum.repos.d/grafana.repo &gt; /dev/null &lt;&lt;'EOF'
[grafana]
name=grafana
baseurl=https://rpm.grafana.com
repo_gpgcheck=1
enabled=1
gpgcheck=1
gpgkey=https://rpm.grafana.com/gpg.key
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
EOF

sudo dnf install -y grafana</code></pre>



<h3 class="wp-block-heading">Bind it to localhost too</h3>



<p class="wp-block-paragraph">Edit <code>/etc/grafana/grafana.ini</code>:</p>



<pre class="wp-block-code"><code>[server]
http_addr = 127.0.0.1
http_port = 3000
domain = metrics.example.com
root_url = https://metrics.example.com/

[security]
admin_user = admin
admin_password = put-something-real-here
cookie_secure = true

[users]
allow_sign_up = false</code></pre>



<p class="wp-block-paragraph">Getting <code>root_url</code> right matters more than it looks. Set it wrong and Grafana generates broken links and redirects behind the proxy, which produces a confusing half-working UI rather than a clean error.</p>



<pre class="wp-block-code"><code>sudo systemctl enable --now grafana-server</code></pre>



<h2 class="wp-block-heading">Step 5: Put nginx and TLS in front</h2>



<p class="wp-block-paragraph">This is the only piece that faces the internet.</p>



<pre class="wp-block-code"><code>server {
    listen 443 ssl;
    server_name metrics.example.com;

    ssl_certificate     /etc/letsencrypt/live/metrics.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/metrics.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Grafana Live needs websockets, and breaks silently without this
    location /api/live/ {
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host       $host;
        proxy_pass http://127.0.0.1:3000;
    }
}</code></pre>



<p class="wp-block-paragraph">Note <code>fullchain.pem</code> rather than <code>cert.pem</code>. Getting that wrong produces a site that works in your browser and fails for anything else.</p>



<p class="wp-block-paragraph">Then close the metrics ports at the firewall, belt and braces:</p>



<pre class="wp-block-code"><code># firewalld
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

# ufw
sudo ufw allow 443/tcp

# Verify nothing unexpected is listening publicly
sudo ss -tlnp | grep -E '9090|9100|3000'</code></pre>



<p class="wp-block-paragraph">Every line of that last command should show <code>127.0.0.1</code>. If you see <code>0.0.0.0</code> or <code>*</code>, something is exposed and you should fix it before going any further.</p>



<h2 class="wp-block-heading">Step 6: Connect Grafana to Prometheus</h2>



<p class="wp-block-paragraph">You can click through the UI, but provisioning it as a file means a rebuilt server comes back identical. Create <code>/etc/grafana/provisioning/datasources/prometheus.yml</code>:</p>



<pre class="wp-block-code"><code>apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://127.0.0.1:9090
    isDefault: true</code></pre>



<p class="wp-block-paragraph">Restart Grafana, log in, and import dashboard ID <strong>1860</strong> from the Grafana dashboard library — Node Exporter Full. It is comprehensive, well maintained, and will show you more about your server than you knew was measurable.</p>



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



<h2 class="wp-block-heading">Sizing the disk before it bites</h2>



<p class="wp-block-paragraph">Prometheus storage is predictable. Roughly:</p>



<pre class="wp-block-code"><code>disk = retention_seconds × samples_per_second × bytes_per_sample</code></pre>



<p class="wp-block-paragraph">Compressed samples land in the region of one to two bytes each. Measure your actual rate rather than guessing — run this in the Prometheus query box:</p>



<pre class="wp-block-code"><code>rate(prometheus_tsdb_head_samples_appended_total[5m])</code></pre>



<p class="wp-block-paragraph">One server with node_exporter at a 15 second interval is a small number, and 30 days fits in a few gigabytes. The number climbs fast once you add exporters, so keep <code>--storage.tsdb.retention.size</code> set as a hard ceiling. Prometheus filling the root partition takes the rest of the box down with it, which is a memorable way to discover your monitoring has no monitoring.</p>



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



<h3 class="wp-block-heading">A target shows DOWN</h3>



<p class="wp-block-paragraph">Work outward from the exporter:</p>



<pre class="wp-block-code"><code>curl -s http://127.0.0.1:9100/metrics | head
sudo systemctl status node_exporter
sudo journalctl -u prometheus -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">On RHEL-family systems with SELinux enforcing, this is frequently SELinux rather than your config. Check before you start changing things:</p>



<pre class="wp-block-code"><code>sudo ausearch -m avc -ts recent</code></pre>



<p class="wp-block-paragraph">If that shows denials, write a policy for them. Do not reach for <code>setenforce 0</code> — it fixes today&#8217;s symptom and quietly removes a layer of protection from the whole machine.</p>



<h3 class="wp-block-heading">Grafana loads but every panel says &#8220;No data&#8221;</h3>



<p class="wp-block-paragraph">Almost always the datasource URL. It must be <code>http://127.0.0.1:9090</code> from Grafana&#8217;s point of view, not your public hostname. Test the datasource from its settings page — Grafana reports the actual connection error, which is more useful than the empty panel.</p>



<h3 class="wp-block-heading">Prometheus will not start</h3>



<pre class="wp-block-code"><code>sudo journalctl -u prometheus -n 50 --no-pager
promtool check config /etc/prometheus/prometheus.yml
ls -la /var/lib/prometheus</code></pre>



<p class="wp-block-paragraph">The usual culprits are a YAML indentation error, or <code>/var/lib/prometheus</code> not being owned by the prometheus user after a manual file copy.</p>



<h3 class="wp-block-heading">Broken styling or redirect loops behind the proxy</h3>



<p class="wp-block-paragraph"><code>root_url</code> in grafana.ini does not match how you are actually reaching the site. If you are serving from a subpath, you also need <code>serve_from_sub_path = true</code>.</p>



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



<ul class="wp-block-list">
<li>Leaving 9090, 9100 or 3000 on a public interface. Prometheus has no authentication whatsoever.</li>

<li>Keeping the default Grafana admin password because it prompts you to change it and you clicked past.</li>

<li>Setting no retention limits and discovering the problem when the root partition fills.</li>

<li>Dropping <code>scrape_interval</code> to 1s because more data seems better. It multiplies storage and buys you nothing at this scale.</li>

<li>Never backing up <code>/var/lib/grafana/grafana.db</code>, which holds every dashboard you have built.</li>

<li>Running everything as root because the tutorial you followed did.</li>

<li>Building dashboards but configuring no alerts, so you still find out from a user.</li>
</ul>



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



<ul class="wp-block-list">
<li>Bind every component to localhost and expose exactly one TLS endpoint.</li>

<li>Set both time and size retention. Whichever triggers first is the one you want.</li>

<li>Provision datasources and dashboards as files, so the config lives in version control.</li>

<li>Back up the Grafana SQLite database on the same schedule as everything else that matters.</li>

<li>Add an external uptime check. Self-hosted monitoring cannot tell you that the server it lives on has gone.</li>

<li>Write a handful of alerts you will genuinely act on. Alerts nobody responds to train people to ignore alerts.</li>

<li>Use systemd&#8217;s sandboxing directives. They cost nothing and limit what a compromised exporter can reach.</li>
</ul>



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



<h3 class="wp-block-heading">Should I use Docker instead?</h3>



<p class="wp-block-paragraph">For a single server, systemd is simpler. Two static binaries and a package, with no container networking layer between you and the logs. Containers earn their keep once you are managing several hosts or already run everything else that way.</p>



<h3 class="wp-block-heading">How much RAM does this need?</h3>



<p class="wp-block-paragraph">A 2 GB VPS is comfortable for one server. Prometheus memory tracks active time series rather than host count, so it grows when you add exporters, not when you add machines with the same exporters.</p>



<h3 class="wp-block-heading">Can I monitor more servers later?</h3>



<p class="wp-block-paragraph">Yes. Install node_exporter on each and add them as targets. The moment metrics cross a network, though, that traffic needs protecting — a VPN, an SSH tunnel, or TLS with client certificates. Do not simply open 9100 to the world.</p>



<h3 class="wp-block-heading">Where do alerts come from?</h3>



<p class="wp-block-paragraph">Two options. Grafana&#8217;s built-in alerting works from dashboard queries and is the quicker path. Alertmanager, run alongside Prometheus, is more powerful for routing, grouping and silencing. Start with Grafana&#8217;s and move if you outgrow it.</p>



<h3 class="wp-block-heading">How long should I keep metrics?</h3>



<p class="wp-block-paragraph">Thirty days covers most troubleshooting and capacity questions. Longer retention on a single box is usually a sign you want a remote-write backend rather than a bigger disk.</p>



<h3 class="wp-block-heading">Do I need node_exporter if I only care about my application?</h3>



<p class="wp-block-paragraph">Install it anyway. When the application misbehaves, the first question is whether the machine underneath it is healthy, and node_exporter is what answers that.</p>



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



<p class="wp-block-paragraph">Once you install Prometheus and Grafana this way, the whole stack is four systemd units and three config files. Everything binds to localhost, nginx handles TLS, and there is no layer of abstraction between you and a problem when something breaks at an inconvenient hour.</p>



<p class="wp-block-paragraph">Get the dashboard working first, then spend an evening writing three or four alerts you will actually respond to. A dashboard tells you what happened when you go looking. An alert is what stops you needing to look.</p>



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



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



<p class="wp-block-paragraph">I build monitoring stacks for teams who would rather not spend a weekend on it — Prometheus and Grafana on bare servers or in Kubernetes, exporters for the things you actually run, alert rules tuned so they mean something, and dashboards that answer real questions rather than filling a screen.</p>



<p class="wp-block-paragraph">If that sounds useful, 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/prometheus-grafana-one-server/">Prometheus + Grafana From Scratch on One Server</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>TLS Certificate Errors: Chain Issues, SANs, and Expiry Automation</title>
		<link>https://john-nessime.com/blog/system-administration/tls-certificate-errors/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 15:02:33 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Certbot]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Let's Encrypt]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[OpenSSL]]></category>
		<category><![CDATA[SSL Certificate]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[TLS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=31</guid>

					<description><![CDATA[<p>Almost every TLS certificate error traces back to one of three causes: a broken chain, a hostname the certificate does not cover, or an expiry nobody was watching. Here is how to tell them apart in under a minute, and how to stop the third one from ever happening again.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/tls-certificate-errors/">TLS Certificate Errors: Chain Issues, SANs, and Expiry Automation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The site loads fine in your browser. Then a mobile app starts throwing handshake failures, a webhook from a payment provider stops arriving, and someone on the team runs <code>curl</code> and gets &#8220;SSL certificate problem: unable to get local issuer certificate&#8221;. Nothing changed on the server. Except something did, and your browser has been quietly covering for it.</p>



<p class="wp-block-paragraph">Nearly every TLS certificate error I have had to chase down comes back to one of three things: the chain your server sends is incomplete, the certificate does not actually cover the hostname being requested, or it expired and no alert fired. This article walks through all three — how to identify which one you are looking at, the exact commands to prove it, and how to set up renewal automation so the third category stops being a category at all.</p>



<h2 class="wp-block-heading">Why TLS certificate errors are so confusing to debug</h2>



<p class="wp-block-paragraph">Two things conspire to make this harder than it should be.</p>



<p class="wp-block-paragraph">The first is that browsers are forgiving in ways other clients are not. When a server sends an incomplete certificate chain, most modern browsers will quietly fetch the missing intermediate using the Authority Information Access extension embedded in the certificate. They fix your misconfiguration on the fly and show you a padlock. Meanwhile <code>curl</code>, Python&#8217;s <code>requests</code>, Java clients, Go services, and older Android devices do no such thing. They fail. This is why &#8220;it works in Chrome&#8221; is worth almost nothing as a diagnostic signal.</p>



<p class="wp-block-paragraph">The second is that error messages describe the symptom rather than the cause. &#8220;Unable to get local issuer certificate&#8221; sounds like a client problem. It usually is not — it usually means your server is not sending the full chain. Learning to translate the message into the actual failure is most of the work.</p>



<h2 class="wp-block-heading">One command that tells you almost everything</h2>



<p class="wp-block-paragraph">Before touching config files, look at what the server is actually sending on the wire:</p>



<pre class="wp-block-code"><code>openssl s_client -connect example.com:443 -servername example.com -showcerts &lt;/dev/null</code></pre>



<p class="wp-block-paragraph">The <code>-servername</code> flag matters more than people expect. It sets SNI, and without it you may be handed the server&#8217;s default certificate rather than the one for the domain you care about. Plenty of confusing &#8220;the certificate is for the wrong domain&#8221; reports come from testing without SNI.</p>



<p class="wp-block-paragraph">Three parts of the output are worth reading carefully:</p>



<ul class="wp-block-list">
<li><strong>The &#8220;Certificate chain&#8221; block</strong> near the top, listing each certificate the server sent with its subject and issuer.</li>

<li><strong>The &#8220;Verify return code&#8221;</strong> at the bottom. <code>0 (ok)</code> is what you want. <code>20</code> and <code>21</code> both point at chain problems.</li>

<li><strong>The subject line of the first certificate</strong>, which tells you whether you even got the certificate you expected.</li>
</ul>



<p class="wp-block-paragraph">If you want the certificate details without the chain noise, pipe it into <code>x509</code>:</p>



<pre class="wp-block-code"><code>echo | openssl s_client -connect example.com:443 -servername example.com 2&gt;/dev/null 
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName</code></pre>



<p class="wp-block-paragraph">That single line answers &#8220;who issued it&#8221;, &#8220;when does it expire&#8221;, and &#8220;what hostnames does it cover&#8221; in one shot. It is the first thing I run.</p>



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



<h2 class="wp-block-heading">Family one: certificate chain issues</h2>



<p class="wp-block-paragraph">A publicly trusted certificate is almost never signed directly by a root. There is at least one intermediate CA in between. The client already trusts the root — it ships in the operating system or browser trust store — but it has no idea about the intermediate unless you hand it over. Your server&#8217;s job is to send the leaf certificate plus every intermediate needed to walk up to a trusted root.</p>



<h3 class="wp-block-heading">The classic mistake: serving the leaf without the intermediates</h3>



<p class="wp-block-paragraph">If you use Let&#8217;s Encrypt, certbot writes several files into <code>/etc/letsencrypt/live/example.com/</code>. Two of them look interchangeable and are not:</p>



<ul class="wp-block-list">
<li><code>cert.pem</code> — the leaf certificate on its own.</li>

<li><code>fullchain.pem</code> — the leaf plus the intermediates.</li>

<li><code>chain.pem</code> — the intermediates on their own.</li>

<li><code>privkey.pem</code> — the private key.</li>
</ul>



<p class="wp-block-paragraph">Nginx wants <code>fullchain.pem</code>. Point it at <code>cert.pem</code> and browsers will still work, so the mistake survives testing and surfaces weeks later when an API client fails.</p>



<pre class="wp-block-code"><code>server {
    listen 443 ssl;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}</code></pre>



<p class="wp-block-paragraph">On Apache 2.4.8 and later, <code>SSLCertificateFile</code> can hold the leaf and the intermediates concatenated together, and <code>SSLCertificateChainFile</code> is deprecated. If you inherited a config that still uses the old directive, it will work, but it is worth cleaning up:</p>



<pre class="wp-block-code"><code>SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem</code></pre>



<h3 class="wp-block-heading">Wrong order, or a root that should not be there</h3>



<p class="wp-block-paragraph">Order is part of the specification, not a suggestion. The leaf comes first, then each issuer in sequence walking upward. Some clients tolerate a shuffled bundle; several do not, and the ones that do not tend to be the ones you find out about from an angry customer.</p>



<p class="wp-block-paragraph">Including the root certificate at the end is a different case: it is harmless from a trust perspective, since the client ignores it and uses its own copy, but it adds bytes to every single handshake for no benefit. Leave it out.</p>



<h3 class="wp-block-heading">Verifying a chain offline</h3>



<p class="wp-block-paragraph">You can check a bundle before deploying it, which is far better than discovering the problem in production:</p>



<pre class="wp-block-code"><code># Does the leaf verify against the intermediates you plan to serve?
openssl verify -untrusted chain.pem cert.pem

# Print the subject and issuer of every certificate in a bundle, in order
openssl crl2pkcs7 -nocrl -certfile fullchain.pem 
  | openssl pkcs7 -print_certs -noout</code></pre>



<p class="wp-block-paragraph">That second command is the one I reach for most. Read the output top to bottom: the issuer of each certificate should be the subject of the next one down. The moment that pattern breaks, you have found your gap.</p>



<h3 class="wp-block-heading">The key and the certificate do not match</h3>



<p class="wp-block-paragraph">Less common, but it produces a spectacularly unhelpful error. After a renewal where files were copied around by hand, confirm the pair still belongs together — the two hashes must be identical:</p>



<pre class="wp-block-code"><code>openssl x509 -noout -modulus -in cert.pem | openssl md5
openssl rsa  -noout -modulus -in privkey.pem | openssl md5</code></pre>



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



<h2 class="wp-block-heading">Family two: SAN mismatches</h2>



<p class="wp-block-paragraph">The Common Name field is dead. It has been deprecated for hostname matching for years, and Chrome stopped honouring it entirely back in 2017. What matters now is the Subject Alternative Name extension. If the hostname a client requested is not in the SAN list, the connection fails, regardless of what the CN says.</p>



<p class="wp-block-paragraph">Check what a certificate actually covers:</p>



<pre class="wp-block-code"><code># From a file
openssl x509 -noout -ext subjectAltName -in cert.pem

# From a live server
echo | openssl s_client -connect example.com:443 -servername example.com 2&gt;/dev/null 
  | openssl x509 -noout -ext subjectAltName</code></pre>



<h3 class="wp-block-heading">The four SAN mistakes that keep recurring</h3>



<ul class="wp-block-list">
<li><strong>Apex without www, or www without apex.</strong> These are two distinct names. A certificate for <code>example.com</code> does not cover <code>www.example.com</code>. Both need to be in the SAN list, and both need to be passed to certbot with separate <code>-d</code> flags.</li>

<li><strong>Assuming a wildcard covers everything.</strong> It does not. <code>*.example.com</code> matches exactly one label. It covers <code>api.example.com</code>. It does not cover <code>example.com</code> itself, and it does not cover <code>staging.api.example.com</code>. Multi-level subdomains need their own wildcard or their own entry.</li>

<li><strong>A new subdomain that never made it into the renewal.</strong> You add <code>metrics.example.com</code> to nginx, point DNS at it, and forget that the certificate was issued for a fixed list of names. Adding a name means reissuing, not just reloading.</li>

<li><strong>Connecting by IP address.</strong> Almost no public certificate includes an IP SAN, so hitting the server by IP will always fail hostname verification. That is expected behaviour, not a broken certificate.</li>
</ul>



<p class="wp-block-paragraph">One more that catches people out: if you use Cloudflare in proxied mode, the certificate a visitor sees is Cloudflare&#8217;s edge certificate, not yours. Debugging the origin means bypassing the proxy and testing the origin IP directly with the <code>Host</code> header set correctly, or temporarily turning the orange cloud grey.</p>



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



<h2 class="wp-block-heading">Family three: expiry, and why automation is no longer optional</h2>



<p class="wp-block-paragraph">Expiry used to be a calendar problem. You bought a certificate for a year, set a reminder, and dealt with it once. That era is closing.</p>



<p class="wp-block-paragraph">In April 2025 the CA/Browser Forum passed ballot SC-081v3, which phases the maximum validity period for publicly trusted TLS certificates down from 398 days to 47 days. The schedule runs in three steps: 200 days from 15 March 2026, 100 days from 15 March 2027, and 47 days from 15 March 2029. The first phase is already in force. Domain validation reuse windows shrink alongside it.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">The 47-day target was chosen deliberately: short enough that manual renewal stops being viable at any meaningful scale. Automation is the point of the change, not a side effect of it.</p>
</blockquote>



<p class="wp-block-paragraph">If you run a handful of domains, this is a nuisance. If you run fifty, manual renewal is already finished as a strategy.</p>



<h3 class="wp-block-heading">Getting ACME renewal right</h3>



<p class="wp-block-paragraph">Certbot installs a systemd timer or cron entry that attempts renewal twice a day and does nothing unless the certificate is close to expiry. Confirm it is actually there:</p>



<pre class="wp-block-code"><code>systemctl list-timers | grep certbot
certbot renew --dry-run</code></pre>



<p class="wp-block-paragraph">The dry run exercises the whole path against the staging environment without touching rate limits. Run it after any change to your web server config, not just after installing certbot. A renewal that worked last quarter can break because someone added a redirect that intercepts the ACME challenge path.</p>



<h3 class="wp-block-heading">The trap: renewal succeeds, the site still serves the old certificate</h3>



<p class="wp-block-paragraph">This is the failure I see most often, and it is genuinely maddening because every log says success. Certbot writes the new certificate to disk. Nginx has the old one loaded in memory and keeps serving it until it is told otherwise. The certificate on disk is valid, the certificate on the wire is expired, and everything looks fine from the server&#8217;s point of view.</p>



<p class="wp-block-paragraph">The fix is a deploy hook, which runs only when a certificate was actually renewed:</p>



<pre class="wp-block-code"><code>certbot renew --deploy-hook "systemctl reload nginx"</code></pre>



<p class="wp-block-paragraph">Better still, make it permanent by adding it to the renewal configuration at <code>/etc/letsencrypt/renewal/example.com.conf</code>, under the <code>[renewalparams]</code> section, so it survives however the renewal happens to be triggered:</p>



<pre class="wp-block-code"><code>renew_hook = systemctl reload nginx</code></pre>



<p class="wp-block-paragraph">Use <code>reload</code> rather than <code>restart</code>. A reload picks up the new certificate without dropping connections.</p>



<p class="wp-block-paragraph">Remember that anything else terminating TLS needs the same treatment. A mail server, a database proxy, a Docker container with the certificate baked into its image — each one needs its own reload step, and containers in particular will happily run for months with an expired certificate mounted from the host.</p>



<h3 class="wp-block-heading">Wildcards need DNS-01</h3>



<p class="wp-block-paragraph">Wildcard certificates cannot be issued through HTTP-01 validation. They require DNS-01, which means certbot needs to create a TXT record on your domain, which means API credentials for your DNS provider. Use a provider plugin rather than the manual mode — manual DNS-01 cannot be automated, and an unautomated wildcard on a 47-day cycle is an outage with a scheduled start time.</p>



<p class="wp-block-paragraph">If issuance fails outright with a policy error, check your CAA records before assuming the ACME client is broken. A CAA record that names a different CA will block issuance entirely:</p>



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



<h3 class="wp-block-heading">Monitor from outside, not from the server</h3>



<p class="wp-block-paragraph">Checking the certificate file on disk tells you what should be served. Only an external check tells you what is being served. Given the reload trap above, that difference is the entire point.</p>



<p class="wp-block-paragraph">A minimal check that works anywhere:</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
# Usage: ./cert-days.sh example.com
# Exits non-zero if fewer than 21 days remain.
set -euo pipefail

DOMAIN="$1"
THRESHOLD=21

END=$(echo | openssl s_client -connect "${DOMAIN}:443" -servername "${DOMAIN}" 2&gt;/dev/null 
      | openssl x509 -noout -enddate | cut -d= -f2)

DAYS=$(( ( $(date -d "${END}" +%s) - $(date +%s) ) / 86400 ))
echo "${DOMAIN}: ${DAYS} days remaining (expires ${END})"

[ "${DAYS}" -lt "${THRESHOLD}" ] &amp;&amp; exit 1
exit 0</code></pre>



<p class="wp-block-paragraph">If you already run Prometheus, the blackbox exporter gives you this for free through the <code>probe_ssl_earliest_cert_expiry</code> metric. An alert rule looks like this:</p>



<pre class="wp-block-code"><code>groups:
  - name: tls
    rules:
      - alert: TLSCertificateExpiringSoon
        expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 &lt; 21
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "TLS certificate expiring in under 21 days"</code></pre>



<p class="wp-block-paragraph">Set the threshold well above your renewal window. With 200-day certificates renewing at 30 days remaining, a 21-day alert gives you nine days of warning that something in the pipeline broke. Once 47-day certificates arrive, those numbers compress hard, and an alert that fires too late is the same as no alert.</p>



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



<h2 class="wp-block-heading">A workflow for diagnosing TLS certificate errors</h2>



<p class="wp-block-paragraph">When something breaks, work through it in this order. It takes about a minute and rules out whole families of causes as you go.</p>



<ol class="wp-block-list">
<li><strong>Reproduce outside the browser.</strong> Use <code>curl -v</code> or <code>openssl s_client</code>. If the browser works and curl does not, you are almost certainly looking at a chain problem.</li>

<li><strong>Read the verify return code.</strong> Code 20 or 21 means a chain issue. A hostname mismatch message means a SAN issue. A date error means expiry — or a wrong system clock on the client.</li>

<li><strong>Print the dates.</strong> Rules expiry in or out in one command, and tells you whether the certificate on the wire matches the one on disk.</li>

<li><strong>Print the SANs.</strong> Confirm the exact hostname being requested appears in the list. Watch for apex versus www.</li>

<li><strong>Walk the chain.</strong> Each certificate&#8217;s issuer should be the next one&#8217;s subject. Find where the sequence breaks.</li>

<li><strong>Compare disk against wire.</strong> If the file is newer than what is being served, you have a reload problem, not a certificate problem.</li>

<li><strong>Check the client&#8217;s trust store.</strong> If everything above is clean and one specific client still fails, the problem has moved to their end — an old Android device, a Java service with its own <code>cacerts</code>, or a container image built on a base with a stale CA bundle.</li>
</ol>



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



<ul class="wp-block-list">
<li>Testing only in a browser, and mistaking its silent chain repair for a working configuration.</li>

<li>Pointing the web server at <code>cert.pem</code> instead of <code>fullchain.pem</code>.</li>

<li>Renewing without reloading the service that holds the certificate in memory.</li>

<li>Running <code>certbot renew</code> on a schedule but never running <code>--dry-run</code> after config changes.</li>

<li>Copying key and certificate files between servers by hand and losing track of which pair belongs together.</li>

<li>Monitoring the file on disk rather than the certificate actually being served.</li>

<li>Forgetting that mail servers, load balancers, and containers terminate TLS too, and need their own renewal path.</li>

<li>Adding a subdomain to the web server config and assuming the existing certificate covers it.</li>
</ul>



<h2 class="wp-block-heading">Best practices that hold up over time</h2>



<ul class="wp-block-list">
<li>Automate issuance and renewal from day one, even for a single domain. The habit matters more than the scale.</li>

<li>Always use a deploy hook, and always reload rather than restart.</li>

<li>Alert on days remaining, measured externally, with a threshold at least twice your renewal window.</li>

<li>Keep an inventory of every hostname you hold a certificate for. You cannot monitor what you have forgotten about.</li>

<li>Prefer DNS-01 with a provider plugin if you need wildcards, and never rely on manual DNS validation.</li>

<li>Set CAA records deliberately so you know which CAs are permitted to issue for your domain.</li>

<li>Test with a non-browser client before calling a certificate deployment finished.</li>
</ul>



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



<h3 class="wp-block-heading">Why does my site work in Chrome but fail in curl?</h3>



<p class="wp-block-paragraph">Almost always an incomplete chain. Browsers fetch missing intermediate certificates automatically using the Authority Information Access extension; curl does not. Point your web server at the full chain file rather than the leaf certificate and both will work.</p>



<h3 class="wp-block-heading">What does &#8220;unable to get local issuer certificate&#8221; actually mean?</h3>



<p class="wp-block-paragraph">The client could not build a path from your certificate to a root it trusts. Nine times out of ten the server is not sending its intermediates. Occasionally the client&#8217;s own CA bundle is out of date, which is common in older container base images.</p>



<h3 class="wp-block-heading">Does a wildcard certificate cover the root domain?</h3>



<p class="wp-block-paragraph">No. <code>*.example.com</code> matches a single label, so it covers <code>www.example.com</code> but not <code>example.com</code> and not <code>a.b.example.com</code>. Include the apex explicitly as an additional SAN entry when you request the certificate.</p>



<h3 class="wp-block-heading">My certificate renewed but the site still shows it as expired. Why?</h3>



<p class="wp-block-paragraph">The service holding the certificate has not reloaded. The new file is on disk; the old one is still in memory. Add a deploy hook that reloads the service on renewal, then verify from outside with <code>openssl s_client</code> rather than trusting the file timestamp.</p>



<h3 class="wp-block-heading">How short will certificate lifetimes actually get?</h3>



<p class="wp-block-paragraph">Under the CA/Browser Forum schedule, 200 days applies from March 2026, 100 days from March 2027, and 47 days from March 2029. Private and internal CAs are not bound by these rules, but public certificates are.</p>



<h3 class="wp-block-heading">Is the Common Name field still used at all?</h3>



<p class="wp-block-paragraph">Not for hostname validation. Modern clients read only the Subject Alternative Name extension. A certificate whose CN matches but whose SAN list does not include the hostname will be rejected.</p>



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



<p class="wp-block-paragraph">TLS certificate errors feel opaque mostly because the error text points at the wrong layer. Once you sort them into chain, SAN, or expiry, each one has a short diagnostic path and an obvious fix. Get comfortable with <code>openssl s_client</code> and <code>openssl x509</code> and you will resolve most incidents faster than it takes to find the right dashboard.</p>



<p class="wp-block-paragraph">The expiry category is the one worth engineering away permanently. With maximum lifetimes dropping toward 47 days, any process that depends on a person remembering something will fail eventually. Automate the renewal, add the deploy hook, and alert on what is actually being served rather than what is sitting on disk. Do that once and TLS certificate errors go back to being a rare curiosity instead of a recurring outage.</p>



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



<h2 class="wp-block-heading">Need help with TLS, certificates, or infrastructure automation?</h2>



<p class="wp-block-paragraph">I work with teams on exactly this kind of problem — broken certificate chains, ACME automation across multiple servers, expiry monitoring wired into Prometheus and Grafana, and the wider DevOps plumbing that keeps it all running quietly in the background.</p>



<p class="wp-block-paragraph">If you would rather hand this off than debug it at two in the morning, 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/system-administration/tls-certificate-errors/">TLS Certificate Errors: Chain Issues, SANs, and Expiry Automation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The Complete WordPress Debugging Handbook: Fix Errors, Improve Performance &#038; Secure Your Website Like a Pro</title>
		<link>https://john-nessime.com/blog/wordpress/wordpress-debugging-guide/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 16 Jul 2026 11:50:04 +0000</pubDate>
				<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Core Web Vitals]]></category>
		<category><![CDATA[Database Optimization]]></category>
		<category><![CDATA[Debug Mode]]></category>
		<category><![CDATA[Fix WordPress]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[PHP Errors]]></category>
		<category><![CDATA[Plugin Conflicts]]></category>
		<category><![CDATA[Theme Issues]]></category>
		<category><![CDATA[Website Speed]]></category>
		<category><![CDATA[Website Troubleshooting]]></category>
		<category><![CDATA[WordPress Debugging]]></category>
		<category><![CDATA[WordPress Errors]]></category>
		<category><![CDATA[WordPress Maintenance]]></category>
		<category><![CDATA[WordPress Security]]></category>
		<category><![CDATA[WP_DEBUG]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=17</guid>

					<description><![CDATA[<p>Learn how to debug WordPress like a professional. Discover how to fix common WordPress errors, optimize performance, improve security, troubleshoot plugins, themes, databases, and server issues with this complete step-by-step guide.</p>
<p>The post <a href="https://john-nessime.com/blog/wordpress/wordpress-debugging-guide/">The Complete WordPress Debugging Handbook: Fix Errors, Improve Performance &amp; Secure Your Website Like a Pro</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[

<p class="wp-block-paragraph">Every WordPress website eventually develops a problem. Sometimes it&#8217;s a white screen that appears out of nowhere. Sometimes the admin dashboard suddenly becomes painfully slow. Other times a plugin update breaks an important feature without any warning.</p>



<p class="wp-block-paragraph">The good news is that most WordPress problems are not random. They leave clues, and if you know where to look, identifying the root cause becomes much easier.</p>



<p class="wp-block-paragraph">After managing WordPress servers and troubleshooting websites over the years, one lesson has become obvious: guessing rarely fixes anything. A structured debugging process almost always does.</p>



<p class="wp-block-paragraph">This guide walks through the practical approach professionals use to diagnose WordPress issues, improve performance, and strengthen website security without creating additional problems.</p>



<h2 class="wp-block-heading">Why WordPress Problems Happen</h2>


<p>WordPress itself is remarkably stable. Most issues originate from the environment around it.</p>

<ul>
<li>Plugin conflicts</li>
<li>Theme compatibility issues</li>
<li>PHP version mismatches</li>
<li>Database corruption</li>
<li>Memory limitations</li>
<li>File permission problems</li>
<li>Server configuration mistakes</li>
<li>Caching conflicts</li>
<li>Security breaches</li>
<li>Poor hosting performance</li>
</ul>

<p>Instead of treating every symptom individually, it&#8217;s more effective to understand which layer is actually responsible.</p>


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


<p>Before changing anything, make a complete backup.</p>

<p>A proper backup should include:</p>

<ul>
<li>Entire WordPress files</li>
<li>Database</li>
<li>Uploads folder</li>
<li>Configuration files</li>
<li>Custom themes</li>
<li>Custom plugins</li>
</ul>

<p>Working without a backup is one of the biggest mistakes beginners make.</p>


<h2 class="wp-block-heading">Enable WordPress Debug Mode</h2>


<p>WordPress includes a powerful debugging system built directly into the core.</p>

<p>Edit the <strong>wp-config.php</strong> file and enable debugging.</p>

<pre><code>define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);</code></pre>

<p>This records PHP warnings and errors inside the debug.log file without exposing them to website visitors.</p>

<p>Never leave debugging enabled permanently on a production website.</p>


<h2 class="wp-block-heading">Common WordPress Errors Explained</h2>


<h3>White Screen of Death</h3>

<p>The website displays nothing except a blank white page.</p>

<p>Typical causes include:</p>

<ul>
<li>PHP fatal errors</li>
<li>Plugin conflicts</li>
<li>Memory exhaustion</li>
<li>Corrupted files</li>
</ul>

<p>The debug log usually reveals the exact file responsible.</p>

<h3>500 Internal Server Error</h3>

<p>This generic server error often results from:</p>

<ul>
<li>Broken .htaccess rules</li>
<li>PHP configuration</li>
<li>File permission issues</li>
<li>Server resource exhaustion</li>
</ul>

<p>Checking the Apache or Nginx error logs is usually the fastest path to identifying the real problem.</p>

<h3>Database Connection Error</h3>

<p>This message means WordPress cannot communicate with MySQL.</p>

<p>Possible reasons include:</p>

<ul>
<li>Incorrect database credentials</li>
<li>Database server unavailable</li>
<li>Corrupted database tables</li>
<li>Exceeded hosting limits</li>
</ul>


<h2 class="wp-block-heading">Finding Plugin Conflicts</h2>


<p>Plugins are responsible for the majority of WordPress issues.</p>

<p>Instead of uninstalling everything randomly:</p>

<ol>
<li>Disable all plugins.</li>
<li>Confirm whether the issue disappears.</li>
<li>Enable plugins one at a time.</li>
<li>Identify the plugin causing the conflict.</li>
</ol>

<p>This systematic approach saves hours of unnecessary troubleshooting.</p>


<h2 class="wp-block-heading">Theme Debugging</h2>


<p>The active theme can introduce problems through:</p>

<ul>
<li>Outdated code</li>
<li>Poor optimization</li>
<li>Deprecated PHP functions</li>
<li>JavaScript conflicts</li>
</ul>

<p>Temporarily switching to a default WordPress theme like Twenty Twenty-Six quickly determines whether the issue originates from the theme.</p>


<h2 class="wp-block-heading">Browser Developer Tools</h2>


<p>Modern browsers include excellent diagnostic tools.</p>

<p>The Developer Console helps identify:</p>

<ul>
<li>JavaScript errors</li>
<li>Failed network requests</li>
<li>Slow resources</li>
<li>CSS conflicts</li>
<li>Mixed HTTPS content</li>
</ul>

<p>Many frontend issues become immediately obvious after opening the browser console.</p>


<h2 class="wp-block-heading">Improving Website Performance</h2>


<p>Performance problems are often easier to detect than to solve.</p>

<p>Start by measuring instead of guessing.</p>

<h3>Check Page Speed</h3>

<p>Review:</p>

<ul>
<li>Largest Contentful Paint (LCP)</li>
<li>Interaction to Next Paint (INP)</li>
<li>Cumulative Layout Shift (CLS)</li>
<li>Time to First Byte (TTFB)</li>
</ul>

<p>These Core Web Vitals provide a realistic picture of user experience.</p>

<h3>Optimize Images</h3>

<ul>
<li>Convert images to WebP</li>
<li>Resize oversized files</li>
<li>Compress before uploading</li>
<li>Lazy load below-the-fold images</li>
</ul>

<h3>Reduce Plugin Bloat</h3>

<p>Installing dozens of plugins increases:</p>

<ul>
<li>Database queries</li>
<li>PHP execution time</li>
<li>JavaScript loading</li>
<li>Memory consumption</li>
</ul>

<p>Only keep plugins that provide measurable value.</p>

<h3>Use Object Caching</h3>

<p>Redis and Memcached dramatically reduce repeated database queries on busy websites.</p>

<h3>Database Maintenance</h3>

<p>Over time databases accumulate:</p>

<ul>
<li>Expired transients</li>
<li>Spam comments</li>
<li>Post revisions</li>
<li>Unused metadata</li>
<li>Orphaned records</li>
</ul>

<p>Cleaning these periodically keeps queries efficient.</p>


<h2 class="wp-block-heading">Server-Level Debugging</h2>


<p>Not every issue originates inside WordPress.</p>

<p>Sometimes the operating system is the real culprit.</p>

<p>Monitor:</p>

<ul>
<li>CPU usage</li>
<li>RAM usage</li>
<li>Disk utilization</li>
<li>Disk I/O</li>
<li>PHP-FPM workers</li>
<li>MySQL performance</li>
<li>Apache workers</li>
<li>Nginx requests</li>
</ul>

<p>Server monitoring often exposes bottlenecks invisible from inside WordPress.</p>


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


<p>Performance degradation can sometimes indicate a compromised website.</p>

<p>Watch for:</p>

<ul>
<li>Unexpected administrator accounts</li>
<li>Unknown plugins</li>
<li>Modified core files</li>
<li>Suspicious PHP files</li>
<li>Spam redirects</li>
<li>Hidden malware</li>
</ul>

<p>If malware is suspected:</p>

<ol>
<li>Take the website offline if necessary.</li>
<li>Create a forensic backup.</li>
<li>Scan every file.</li>
<li>Replace WordPress core files.</li>
<li>Rotate all passwords.</li>
<li>Regenerate security keys.</li>
<li>Review access logs.</li>
</ol>


<h2 class="wp-block-heading">Useful Log Files</h2>


<p>Professional troubleshooting relies heavily on logs.</p>

<ul>
<li>WordPress debug.log</li>
<li>Apache error.log</li>
<li>Nginx error.log</li>
<li>PHP-FPM logs</li>
<li>MySQL logs</li>
<li>Server syslog</li>
<li>Security audit logs</li>
</ul>

<p>Learning to read logs is one of the most valuable troubleshooting skills a WordPress administrator can develop.</p>


<h2 class="wp-block-heading">A Practical Debugging Workflow</h2>


<ol>
<li>Reproduce the problem.</li>
<li>Check browser console.</li>
<li>Enable debug logging.</li>
<li>Review server logs.</li>
<li>Disable plugins.</li>
<li>Switch themes.</li>
<li>Verify PHP compatibility.</li>
<li>Check database health.</li>
<li>Test server resources.</li>
<li>Validate the fix.</li>
<li>Monitor after deployment.</li>
</ol>

<p>Following the same workflow every time eliminates unnecessary trial and error.</p>


<h2 class="wp-block-heading">Preventing Future Problems</h2>


<p>The easiest issue to solve is the one that never happens.</p>

<p>Good maintenance habits include:</p>

<ul>
<li>Automatic backups</li>
<li>Routine updates</li>
<li>Security monitoring</li>
<li>Uptime monitoring</li>
<li>Performance dashboards</li>
<li>Database optimization</li>
<li>Log reviews</li>
<li>Staging before production deployments</li>
</ul>

<p>Preventive maintenance significantly reduces downtime and emergency fixes.</p>


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


<p>Debugging WordPress is less about memorizing error messages and more about following a logical process. Every warning, failed request, or slow query tells part of the story. By collecting evidence instead of making assumptions, you can solve problems faster and with greater confidence.</p>

<p>Whether you&#8217;re maintaining a personal blog, managing business websites, or supporting client projects, investing time in understanding debugging techniques pays dividends in stability, performance, and security. A well-maintained WordPress site not only loads faster and ranks better in search engines but also provides visitors with a more reliable and trustworthy experience.</p>


<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>Reliable websites are not built by avoiding problems—they&#8217;re built by knowing exactly how to diagnose and solve them when they appear.</p>
</blockquote>

<p>The post <a href="https://john-nessime.com/blog/wordpress/wordpress-debugging-guide/">The Complete WordPress Debugging Handbook: Fix Errors, Improve Performance &amp; Secure Your Website Like a Pro</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
