<?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>Production | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/production/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/production/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 16:57:45 +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>Production | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/production/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Grafana Alert Notifications That Actually Arrive: Email, Slack and Telegram</title>
		<link>https://john-nessime.com/blog/devops/grafana-alert-notifications-email-slack-telegram/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 27 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Alertmanager]]></category>
		<category><![CDATA[Contact Points]]></category>
		<category><![CDATA[Email Deliverability]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Notification Policies]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Slack]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Telegram]]></category>
		<category><![CDATA[Webhooks]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=258</guid>

					<description><![CDATA[<p>The rule went red at 02:04 and nobody found out until 09:15. Rule evaluation and notification delivery are two different systems, and only one of them is visible in the UI. A practical setup for Grafana alert notifications to email, Slack and Telegram, plus how to detect the day delivery quietly stops.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/grafana-alert-notifications-email-slack-telegram/">Grafana Alert Notifications That Actually Arrive: Email, Slack and Telegram</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The disk filled at 02:00. The rule went red at 02:04. Nobody found out until 09:15, when someone opened the alert list, saw a Firing rule with a five-hour age on it, and asked the obvious question: why didn&#8217;t anyone get paged?</p>



<p class="wp-block-paragraph">The rule was fine. The query was fine. The evaluation ran on schedule and the state transition happened exactly when it should have. What failed was everything after that, and none of it made a sound.</p>



<p class="wp-block-paragraph">That is the failure mode worth designing around. Rule evaluation and notification delivery are two different systems, and a red rule only proves the first one works. Grafana alert notifications run against SMTP servers, Slack&#8217;s API and Telegram&#8217;s bot endpoint, all of which fail in their own quiet ways. A rejected login, a revoked webhook, a message over a length limit: each produces a log line and nothing else. The alert list still shows the rule firing, so everything looks healthy from the one place you&#8217;d think to check.</p>



<p class="wp-block-paragraph">This post covers the practical setup for the three channels most small and mid-sized teams actually use: email, Slack and Telegram. It covers what breaks in each of them, how to route alerts so the right ones interrupt people and the rest don&#8217;t, how to provision the whole thing as files instead of clicking through the UI, and how to detect the case where delivery has stopped entirely.</p>



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



<h2 class="wp-block-heading">Where a firing alert actually goes</h2>



<p class="wp-block-paragraph">Before touching any integration, it helps to know how many hops sit between &#8220;the rule fired&#8221; and &#8220;my phone buzzed&#8221;.</p>



<ul class="wp-block-list">
<li>The rule evaluates on its own schedule and produces alert instances, each carrying labels.</li>
<li>Those instances go to an Alertmanager. Grafana ships its own and can also use an external Prometheus Alertmanager.</li>
<li>The Alertmanager applies mute timings, groups instances, and walks the notification policy tree for a match.</li>
<li>The matched policy names a contact point, which holds one or more integrations.</li>
<li>Each integration makes its own outbound call to a third-party service.</li>
</ul>



<p class="wp-block-paragraph">Steps one and two are visible in the Grafana UI. Steps three through five are where things go wrong, and they are visible only in logs. That asymmetry is the whole problem.</p>



<p class="wp-block-paragraph">One thing worth knowing early: a contact point can hold several integrations at once, so a single contact point named <code>critical</code> can fire email, Slack and Telegram together. Building one contact point per channel instead makes routing much harder than it needs to be.</p>



<h2 class="wp-block-heading">Email: the channel everyone assumes works</h2>



<p class="wp-block-paragraph">Email is the one people configure first and verify least. The catch is that the email contact point holds only the recipient addresses. The actual SMTP credentials live in <code>grafana.ini</code>, so a perfectly valid contact point can sit there doing nothing because the server underneath was never configured.</p>



<pre class="wp-block-code"><code>[smtp]
enabled = true
host = smtp.example.com:587
user = alerts@example.com
# Wrap the password in triple quotes if it contains # or ;
password = """your-smtp-password"""
from_address = alerts@example.com
from_name = Grafana Alerts
skip_verify = false
# Default is OpportunisticStartTLS
startTLS_policy = MandatoryStartTLS</code></pre>



<p class="wp-block-paragraph">Several details in that block matter more than they look.</p>



<ul class="wp-block-list">
<li><strong>The port is part of <code>host</code>.</strong> There is no separate port setting. Port 587 with STARTTLS is the normal choice; 465 is implicit TLS and behaves differently, which is usually why one fails cleanly and the other hangs.</li>
<li><strong><code>startTLS_policy</code> decides how hard Grafana insists on encryption.</strong> The default is opportunistic: it upgrades the connection if the server offers it and quietly continues in the clear if not. On a relay you control, mandatory turns a silent downgrade into a visible failure.</li>
<li><strong><code>from_address</code> has to be something the relay accepts.</strong> Managed relays reject mail from unverified domains <em>after</em> the connection succeeds, which is why the Grafana side looks fine.</li>
<li><strong>Leave <code>skip_verify</code> false.</strong> It exists for internal relays with self-signed certificates. Turning it on to clear an error removes the check that was telling you the certificate is wrong.</li>
</ul>



<p class="wp-block-paragraph">Changes to this file need a Grafana restart. Nothing in the UI reloads SMTP settings.</p>



<h3 class="wp-block-heading">Deliverability is a separate problem from delivery</h3>



<p class="wp-block-paragraph">Sending directly from a VPS is the fastest way to get alerts filed as spam. A fresh IP range from a provider like Contabo or InterServer has no sending reputation, and a mail server that emits one message a week during incidents never builds one.</p>



<p class="wp-block-paragraph">Point Grafana at a transactional relay instead. Amazon SES, Postmark, Mailgun and Brevo all do the same job at this volume. Whichever you pick, publish SPF and DKIM records for the sending domain and set a DMARC policy, then send a real alert to a Gmail address and a Microsoft 365 address and check the spam folder. That is the only test that tells you anything.</p>



<h2 class="wp-block-heading">Slack: webhook or bot token</h2>



<p class="wp-block-paragraph">Grafana supports two ways of posting to Slack, and the choice has consequences beyond setup time.</p>



<p class="wp-block-paragraph"><strong>Incoming webhooks</strong> are the quick path. Slack generates a URL under <code>hooks.slack.com/services/</code> that is bound to a single channel, and Grafana posts to it. There&#8217;s no OAuth flow and no scopes to reason about. The trade-off is that the URL <em>is</em> the credential and it maps to exactly one channel, so a team with six alert channels ends up managing six secrets with no central view of them.</p>



<p class="wp-block-paragraph"><strong>A Slack app with a bot token</strong> takes longer. You create the app, add the <code>chat:write.public</code> scope so it can post to public channels without being invited to each one, then copy the bot user OAuth token, which starts with <code>xoxb-</code>. In Grafana you supply that token plus the channel ID as the recipient. One credential covers every channel, permissions are visible in one place, and revoking it kills all posting at once.</p>



<p class="wp-block-paragraph">Webhooks win when you have one or two channels and want to be done in five minutes. The bot token wins as soon as channels start multiplying or someone asks what the integration is permitted to do. I reach for the webhook on small setups and regret it around the fourth channel.</p>



<p class="wp-block-paragraph">Two constraints either way. A Slack integration targets one channel, so several channels means several integrations. And notification templates control the message title and body text, not its visual structure, so rich block layouts aren&#8217;t on the table.</p>



<h2 class="wp-block-heading">Telegram: cheap paging, one nasty edge</h2>



<p class="wp-block-paragraph">Telegram is genuinely useful for out-of-hours alerts because it pushes to a phone without anyone paying for a paging product. The setup is two values: a bot API token and a chat ID.</p>



<ol class="wp-block-list">
<li>Message BotFather and send <code>/newbot</code>. The username has to end in <code>bot</code> or <code>_bot</code>.</li>
<li>Copy the HTTP API token it returns. That&#8217;s the bot API token Grafana wants.</li>
<li>Create a group for alerts and add the bot to it. A bot cannot message a chat it has never seen.</li>
<li>Send a slash command in the group, such as <code>/start@your_bot_name</code>. An explicit command is the reliable way to make the group visible to the bot.</li>
<li>Fetch the chat ID from the Telegram API.</li>
</ol>



<pre class="wp-block-code"><code>curl -s "https://api.telegram.org/bot&lt;YOUR_BOT_TOKEN&gt;/getUpdates" | jq '.result[].message.chat'</code></pre>



<p class="wp-block-paragraph">The <code>id</code> field in that output is what goes in Grafana&#8217;s Chat ID field. Group chat IDs are negative. Copy the minus sign; leaving it off is the single most common reason a Telegram contact point tests as failing.</p>



<p class="wp-block-paragraph">Now the edge case, and it&#8217;s the reason Telegram belongs in a post about invisible failures. Telegram caps messages at 4096 UTF-8 characters. If you&#8217;re using a parse mode other than none, truncating at that boundary can leave the markup unbalanced, and Telegram rejects the whole message as malformed. You don&#8217;t get a shortened alert. You get nothing.</p>



<p class="wp-block-paragraph">This is exactly backwards, because the message most likely to blow the limit is a large grouped alert during a real outage. The channel works perfectly for months of single alerts, then goes quiet the first time forty instances fire together. Three ways to deal with it, in the order I&#8217;d try them:</p>



<ul class="wp-block-list">
<li>Write a deliberately short notification template for Telegram: alert name, severity, instance count and a dashboard link. Treat it as a pager, not a report.</li>
<li>Narrow <code>group_by</code> on the policy that feeds Telegram so a single notification covers fewer instances.</li>
<li>Set the parse mode to none, which makes truncation harmless at the cost of formatting.</li>
</ul>



<h2 class="wp-block-heading">Routing: split by severity, not by tool</h2>



<p class="wp-block-paragraph">The most common design mistake is a policy tree organised around channels: a Slack policy, an email policy, a Telegram policy. That gives you no way to express the thing you actually care about, which is that some alerts should wake someone up and most should not.</p>



<p class="wp-block-paragraph">Put severity on the alert rule as a label and route on that instead. Contact points then become bundles of channels chosen to match urgency.</p>



<ul class="wp-block-list">
<li><code>severity=critical</code> goes to a contact point holding Telegram and Slack. Something is broken and someone needs to look now.</li>
<li><code>severity=warning</code> goes to Slack only, in a channel people read during working hours.</li>
<li>Everything else falls through to the default policy and lands in email, where it accumulates without interrupting anyone.</li>
</ul>



<p class="wp-block-paragraph">Four timing settings control how noisy this is, and each one does something specific:</p>



<ul class="wp-block-list">
<li><strong><code>group_by</code></strong> decides what counts as the same notification. Grouping by <code>alertname</code> and a service label means one cascading failure produces one message instead of forty.</li>
<li><strong><code>group_wait</code></strong> (default 30 seconds) is how long to hold the first notification for a new group, so related instances that fire a few seconds apart arrive together.</li>
<li><strong><code>group_interval</code></strong> (default 5 minutes) is the minimum gap before a group that has already notified sends again because new instances joined it.</li>
<li><strong><code>repeat_interval</code></strong> (default 4 hours) is how often an unresolved alert re-notifies with nothing new to say.</li>
</ul>



<p class="wp-block-paragraph">The temptation is to drop <code>repeat_interval</code> to fifteen minutes so nothing gets forgotten. Resist it. Teams that do this stop reading the channel within a month, which converts a routing problem into a much worse cultural one. Longer intervals plus a real escalation path beats short intervals every time.</p>



<p class="wp-block-paragraph">Grafana also offers a simpler path: pick a contact point directly on the alert rule form and skip label matching entirely. Reasonable when a rule has one obvious destination. The trade-off is that routing knowledge moves into individual rules, so changing where a class of alerts goes means editing every rule instead of one policy.</p>



<h2 class="wp-block-heading">Provision it as files, not clicks</h2>



<p class="wp-block-paragraph">Contact points built through the UI are invisible to review, easy to delete by accident, and impossible to recreate after a bad restore. If Grafana alert notifications matter enough to build, they matter enough to keep in Git. Grafana reads alerting configuration from files in the <code>provisioning/alerting</code> directory under its provisioning path at startup.</p>



<p class="wp-block-paragraph">Before writing any YAML by hand, build one contact point in the UI, test it, then use the export option on the contact points list to dump it as YAML. That gives you the exact setting keys for that integration type instead of the ones you half-remember from a blog post. Setting names are not consistent between integration types, and a guessed key produces a config that loads without error and sends nothing.</p>



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

contactPoints:
  - orgId: 1
    name: critical-oncall
    receivers:
      # Telegram receiver goes here. Export it from the UI first:
      # the setting key names differ from what you would guess.
      - uid: critical-slack
        type: slack
        settings:
          url: https://hooks.slack.com/services/REPLACE/WITH/YOURS
        disableResolveMessage: false

  - orgId: 1
    name: warnings-slack
    receivers:
      - uid: warnings-slack
        type: slack
        settings:
          url: https://hooks.slack.com/services/REPLACE/WITH/YOURS

  - orgId: 1
    name: email-digest
    receivers:
      - uid: email-digest
        type: email
        settings:
          addresses: ops@example.com
          singleEmail: false</code></pre>



<p class="wp-block-paragraph">And the policy tree that uses them:</p>



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

policies:
  - orgId: 1
    receiver: email-digest
    group_by: [grafana_folder, alertname]
    group_wait: 30s
    group_interval: 5m
    repeat_interval: 4h
    routes:
      - receiver: critical-oncall
        matchers:
          - severity = critical
        group_wait: 10s
        repeat_interval: 1h
      - receiver: warnings-slack
        matchers:
          - severity = warning
        repeat_interval: 12h</code></pre>



<p class="wp-block-paragraph">Two things to know about provisioned resources. By default they become read-only in the UI, which is the point but surprises people the first time. And a contact point that a policy references cannot be deleted until the policy stops referencing it, so removals have to happen in the right order.</p>



<p class="wp-block-paragraph">Keep tokens out of the files themselves. Grafana supports environment variable substitution in provisioning files, which means the YAML can live in Git and the secrets can come from your existing secret store.</p>



<h2 class="wp-block-heading">Detecting the silence</h2>



<p class="wp-block-paragraph">Everything above still leaves the original problem unsolved. If the Slack app gets revoked or the SMTP password rotates, notifications stop and Grafana does not tell you.</p>



<p class="wp-block-paragraph">The fix is a heartbeat alert, and it&#8217;s worth the twenty minutes. Create a rule whose condition is always true, give it a low severity label, route it to each contact point with a short <code>repeat_interval</code>, and point the resulting notification at an external dead-man&#8217;s-switch service. Healthchecks.io, Cronitor and Better Stack all offer this, and Healthchecks.io can be self-hosted if you&#8217;d rather not add a dependency. The service expects a signal on a schedule and alerts you when it stops arriving.</p>



<p class="wp-block-paragraph">The logic is inverted on purpose. You aren&#8217;t monitoring infrastructure here, you&#8217;re monitoring the path that tells you about your infrastructure, and it has to be watched by something that isn&#8217;t itself.</p>



<p class="wp-block-paragraph">As a second layer, Grafana exposes its own internal metrics on a Prometheus-compatible endpoint, including counters for notification attempts and failures. If you&#8217;re already scraping Grafana, alert on that failure counter increasing. It won&#8217;t catch a credential that works but delivers to a dead mailbox, which is why the heartbeat comes first.</p>



<h2 class="wp-block-heading">Troubleshooting: the rule fires, nothing arrives</h2>



<p class="wp-block-paragraph">Work down this list in order. Each step rules out one hop, and stopping early is how people spend an afternoon on the wrong layer.</p>



<ol class="wp-block-list">
<li><strong>Test the contact point in isolation.</strong> The Test button sends a notification without touching rules or policies. If it fails, the problem is credentials or network and nothing else matters yet.</li>
<li><strong>Watch the Grafana logs during the test.</strong> On a systemd install, run <code>journalctl -u grafana-server -f</code> while you click Test. SMTP, Slack and Telegram delivery errors all surface here, usually specific enough to fix directly.</li>
<li><strong>Compare the instance&#8217;s labels against your matchers.</strong> A policy matching <code>severity = critical</code> never sees <code>severity = Critical</code>. Matchers are exact and case-sensitive. Read the firing instance&#8217;s real labels, not the ones you think the rule sets.</li>
<li><strong>Look for a policy earlier in the tree that swallowed it.</strong> Evaluation stops at the first match unless the policy continues. A broad route near the top silently absorbs everything below it.</li>
<li><strong>Check active silences and mute timings.</strong> A silence created during last month&#8217;s maintenance with a generous matcher is the classic. So is a mute timing in the wrong timezone.</li>
<li><strong>Check whether you&#8217;re inside <code>repeat_interval</code>.</strong> A red alert now does not mean a notification is due now. This looks identical to a broken integration and isn&#8217;t.</li>
<li><strong>For email, read the relay&#8217;s own logs.</strong> Grafana handing the message off is not delivery. Bounces and spam filing happen after Grafana&#8217;s involvement ends.</li>
</ol>



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



<ul class="wp-block-list">
<li><strong>Configuring the email contact point and never touching <code>grafana.ini</code>.</strong> The contact point is only a recipient list. Without the SMTP block, it has nothing to send with.</li>
<li><strong>Routing by channel instead of severity.</strong> It feels tidy and it makes urgency impossible to express.</li>
<li><strong>Dropping the minus sign from a Telegram group chat ID.</strong> Group IDs are negative and the field takes the value literally.</li>
<li><strong>Sending verbose templates to Telegram.</strong> The 4096-character limit turns your biggest incident into your quietest one.</li>
<li><strong>Testing with the Test button and calling it done.</strong> Test bypasses the policy tree entirely. It proves the credential works, not that a real alert would ever reach it.</li>
<li><strong>Leaving resolved notifications on for every channel.</strong> Resolution messages in a pager channel double the volume and train people to ignore it.</li>
<li><strong>Storing webhook URLs and bot tokens in the UI only.</strong> They exist in one place, are invisible to review, and disappear with the database.</li>
</ul>



<h2 class="wp-block-heading">Best practices for Grafana alert notifications</h2>



<ul class="wp-block-list">
<li>Set a consistent <code>severity</code> label on every rule from day one. Retrofitting labels across dozens of rules is miserable work.</li>
<li>Use one contact point per urgency level, holding as many integrations as that level needs.</li>
<li>Put a dashboard or runbook link in an annotation your templates render. An alert that says what broke but not where to look costs the responder five minutes every time.</li>
<li>Write channel-specific templates: short for Telegram, richer for email, in between for Slack.</li>
<li>Provision contact points and policies from files, with secrets injected from the environment, and run a heartbeat through every channel.</li>
<li>Re-test every integration after credential rotation, Slack workspace changes or a Grafana upgrade. Those are the three events that quietly break delivery.</li>
</ul>



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



<h3 class="wp-block-heading">Can one contact point send to email, Slack and Telegram at the same time?</h3>



<p class="wp-block-paragraph">Yes. A contact point holds multiple integrations and every one of them fires when that contact point is selected. This is the right way to build an escalation tier: one contact point per severity, with the channel mix chosen to match how urgently that severity needs attention.</p>



<h3 class="wp-block-heading">Why does the Test button work but real alerts never arrive?</h3>



<p class="wp-block-paragraph">Test sends straight to the contact point and skips the policy tree. If it works and real alerts don&#8217;t, the break is in routing: matchers that don&#8217;t match, a broader policy earlier in the tree absorbing the alert, an active silence, or a mute timing. Open the firing instance, read its real labels, and compare them character by character against your matchers.</p>



<h3 class="wp-block-heading">Do I need Grafana Cloud for any of this?</h3>



<p class="wp-block-paragraph">No. Contact points, notification policies, templates and all three integrations ship in the open source build. Grafana Cloud handles hosting and adds on-call tooling on top, which matters if you need rotations and escalation chains. For a team routing alerts to three channels, a self-hosted instance on a modest VPS does everything described here.</p>



<h3 class="wp-block-heading">Should I use a Slack webhook or a bot token?</h3>



<p class="wp-block-paragraph">Webhook for one or two channels where speed of setup matters. Bot token once you have several channels, want one revocable credential, or need to see in one place what the integration is allowed to do. Both deliver identically; the difference is entirely in credential management.</p>



<h3 class="wp-block-heading">Why did my Telegram alerts stop during a big outage?</h3>



<p class="wp-block-paragraph">Almost certainly the 4096-character limit. A large grouped notification exceeded it, truncation broke the message markup, and Telegram rejected the send outright. Shorten the template for that channel, group more narrowly, or drop the parse mode. It&#8217;s the failure mode most worth guarding against, because it only shows up when you need the channel most.</p>



<h3 class="wp-block-heading">Can I keep using the Prometheus Alertmanager I already run?</h3>



<p class="wp-block-paragraph">Yes. Grafana can send to an external Alertmanager instead of its built-in one, and the contact point selector at the top of the alerting pages lets you choose which one you&#8217;re configuring. If you already have a mature Alertmanager routing config, keeping it and pointing Grafana at it is usually less work than migrating the tree.</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">A firing rule proves evaluation works. It proves nothing about whether anyone was told. Grafana alert notifications cross three or four systems after the rule turns red, and every one of them fails quietly by default.</p>



<p class="wp-block-paragraph">So build the channels carefully, route by severity rather than by tool, then run a heartbeat through every path and let something outside Grafana watch it. The alternative is finding out during the incident, which is the one time you have no attention to spare.</p>



<h2 class="wp-block-heading">Need alerting that reaches people?</h2>



<p class="wp-block-paragraph">Most of the Grafana setups I get asked to look at have good dashboards and alerting nobody trusts. Work I take on here:</p>



<ul class="wp-block-list">
<li>Fixing email delivery end to end: SMTP config, relay selection, SPF, DKIM and DMARC so alerts stop landing in spam.</li>
<li>Setting up Slack and Telegram contact points properly, including bot apps, scoped tokens and channel-appropriate templates.</li>
<li>Designing a policy tree around severity labels, with grouping and timing tuned so people keep reading the channel.</li>
<li>Converting click-built alerting into provisioned YAML under version control, with secrets kept out of Git.</li>
<li>Adding heartbeat monitoring so a broken notification path announces itself instead of waiting for an outage.</li>
<li>Auditing alert rules for missing labels, missing runbook links, and rules that fire constantly and are therefore ignored.</li>
</ul>



<p class="wp-block-paragraph">Send me your notification policy export and the output of <code>journalctl -u grafana-server</code> from a period when an alert should have gone out, and I&#8217;ll tell you which hop is dropping it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/grafana-alert-notifications-email-slack-telegram/">Grafana Alert Notifications That Actually Arrive: Email, Slack and Telegram</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cut AWS Costs Without Breaking Production: A Blast-Radius Playbook</title>
		<link>https://john-nessime.com/blog/devops/cut-aws-costs-without-breaking-production/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 26 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon EBS]]></category>
		<category><![CDATA[Amazon EC2]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Cost Explorer]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[NAT Gateway]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Reserved Instances]]></category>
		<category><![CDATA[Savings Plans]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[VPC]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=264</guid>

					<description><![CDATA[<p>Most AWS cost work goes wrong because it starts with the biggest line item, which is also the riskiest. Order the work by blast radius instead: free networking and storage fixes first, performance envelopes one workload at a time, and commitments last. Includes the commands to find the waste and the cuts that look harmless and are not.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/cut-aws-costs-without-breaking-production/">Cut AWS Costs Without Breaking Production: A Blast-Radius Playbook</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 arrives on the third or fourth of the month, and it is never phrased as a question. Someone from finance pastes a screenshot of the AWS bill into a channel, adds a number and a percentage, and waits. Nobody says &#8220;please cause an outage.&#8221; What they say is &#8220;can we get this down.&#8221;</p>



<p class="wp-block-paragraph">That is where most cost work goes wrong. The pressure is on the total, so people attack the biggest line item first. The biggest line item is almost always compute, and compute is the one place where a wrong guess shows up as latency at peak. Three weeks later the bill is lower and the p99 is worse, and nobody connects the two, because the change that caused it was a Tuesday afternoon instance swap that &#8220;went fine.&#8221;</p>



<p class="wp-block-paragraph">This post is about how to cut AWS costs without breaking production by ordering the work differently: not by how much each change saves, but by how far the damage travels if the change is wrong. I will go through the levers in that order, with the traps in each one, the commands I use to find the waste, and the handful of cuts that look harmless and are not.</p>



<h2 class="wp-block-heading">The cuts that break production are rarely the ones you fear</h2>



<p class="wp-block-paragraph">The line items that look most alarming on a bill are usually networking and storage overhead: NAT Gateway processing, public IPv4 hours, log ingestion, snapshots nobody owns. They feel dangerous to touch because they sit in the middle of the VPC. In practice, almost all of them can be removed or rerouted with zero behavioural change to your application. A gateway endpoint for S3 does not alter a single byte your code sees. Deleting an Elastic IP associated with nothing cannot affect anything, by definition.</p>



<p class="wp-block-paragraph">The changes that feel safe are the ones with real blast radius. Dropping an instance one size. Switching a volume type. Moving objects to a colder storage class. Each is a single API call, reversible on paper, and each quietly narrows a performance envelope you were relying on without knowing it. The failure does not show up at deploy time. It shows up the next time you hit the load level that used to fit inside the old headroom.</p>



<p class="wp-block-paragraph">So the rule I work by: <strong>sequence cost work by blast radius, not by savings size</strong>. Take everything production cannot possibly notice first. Then the things that move a performance envelope, one workload at a time, with a metric to watch. Then, last, the financial commitments, which cannot break your uptime at all but can lock you into paying for architecture you are about to throw away.</p>



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



<h2 class="wp-block-heading">First, find out what you are actually paying for</h2>



<p class="wp-block-paragraph">The default Cost Explorer view groups by service, which is close to useless here. &#8220;EC2 &#8211; Other&#8221; is not a thing you can act on. It is a bucket holding EBS volumes, snapshots, NAT Gateway hours, NAT Gateway data processing, data transfer and public IPv4 charges, all mixed together.</p>



<h3 class="wp-block-heading">Group by usage type instead</h3>



<p class="wp-block-paragraph">Usage type names the actual mechanism being billed. Strings like <code>NatGateway-Bytes</code>, <code>NatGateway-Hours</code>, <code>PublicIPv4:InUseAddress</code> and <code>EBS:VolumeUsage.gp2</code> tell you what to go fix. This pulls last month&#8217;s usage types sorted by cost:</p>



<pre class="wp-block-code"><code>aws ce get-cost-and-usage 
  --time-period Start=$(date -d '1 month ago' +%Y-%m-01),End=$(date +%Y-%m-01) 
  --granularity MONTHLY 
  --metrics UnblendedCost 
  --group-by Type=DIMENSION,Key=USAGE_TYPE 
  --output json 
  | jq -r '.ResultsByTime[0].Groups[] | [.Keys[0], .Metrics.UnblendedCost.Amount] | @tsv' 
  | sort -k2 -gr | head -30</code></pre>



<p class="wp-block-paragraph">That uses GNU <code>date</code>, so run it on Linux unless you have coreutils on macOS. Cost Explorer API calls are themselves billed per request, which surprises people the first time they wrap this in a cron job.</p>



<h3 class="wp-block-heading">Billing data cannot tell you where your bytes went</h3>



<p class="wp-block-paragraph">This is the most useful thing to internalise about networking spend. Cost data knows how many gigabytes crossed the NAT Gateway. It has no idea whether they were headed to S3, to ECR, to CloudWatch or to a third-party API. There is no destination breakdown in billing data.</p>



<p class="wp-block-paragraph">VPC Flow Logs are the missing half. Turn them on for the private subnets, then query them grouped by destination address with Athena or CloudWatch Logs Insights and map the ranges back to AWS services. Now you have the split: this much to S3, this much to ECR, this much genuinely to the internet. That is what tells you which endpoint is worth adding. Flow Logs cost money to ingest, so scope them tightly and turn them off once you have the answer. A week on the right subnets is enough.</p>



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



<h2 class="wp-block-heading">Tier zero: savings production cannot notice</h2>



<p class="wp-block-paragraph">Everything in this tier changes billing without changing behaviour. Do all of it before you touch a single instance type.</p>



<h3 class="wp-block-heading">The orphan sweep</h3>



<p class="wp-block-paragraph">AWS charges for allocated resources, not used ones. A volume attached to nothing bills at full rate. An Elastic IP attached to nothing bills by the hour, and has done ever since AWS began charging for public IPv4 addresses whether or not they are in use.</p>



<pre class="wp-block-code"><code># Elastic IPs associated with nothing
aws ec2 describe-addresses 
  --query 'Addresses[?AssociationId==`null`].[PublicIp,AllocationId]' 
  --output text

# EBS volumes in the "available" state, meaning attached to no instance
aws ec2 describe-volumes --filters Name=status,Values=available 
  --query 'Volumes[].[VolumeId,Size,VolumeType,CreateTime]' 
  --output text</code></pre>



<p class="wp-block-paragraph">Run the same pass over load balancers with no healthy targets, snapshots whose source volume is gone, and old AMIs still holding snapshots behind them. Snapshot a volume before deleting it. The snapshot costs a fraction of the volume and buys you a rollback if something did care after all.</p>



<h3 class="wp-block-heading">NAT Gateway is usually the largest risk-free win</h3>



<p class="wp-block-paragraph">A NAT Gateway bills three separate ways: an hourly charge for existing, a per-gigabyte charge for every byte it processes, and then whatever data transfer applies to where that traffic actually went. Teams look at the hourly rate, decide it is a rounding error, and never look at the processing line. The processing line is the one that grows with traffic.</p>



<p class="wp-block-paragraph">The part that stings: traffic from a private subnet to S3 or DynamoDB in the same region is charged NAT processing even though it never leaves the AWS network. Gateway endpoints for those two services are free, with no hourly and no per-gigabyte charge. If your private subnets talk to S3 through a NAT Gateway, this is the best ratio of saving to risk available anywhere on the platform.</p>



<pre class="wp-block-code"><code>aws ec2 create-vpc-endpoint 
  --vpc-id vpc-0123456789abcdef0 
  --service-name com.amazonaws.eu-west-1.s3 
  --vpc-endpoint-type Gateway 
  --route-table-ids rtb-0123456789abcdef0</code></pre>



<p class="wp-block-paragraph">A gateway endpoint works by adding a prefix-list route to the route tables you name, so the main failure mode is naming the wrong tables and leaving traffic on the old path. Nothing breaks; you just do not save anything. The real caveat is policy: if you have bucket policies or endpoint policies that restrict access by source, check them first, because requests arriving through an endpoint present differently.</p>



<p class="wp-block-paragraph">Interface endpoints for other services (ECR, CloudWatch Logs, Secrets Manager, SSM) are a different calculation. They charge per hour per availability zone plus per gigabyte, so they only pay off above a certain volume for that one service. Use the Flow Logs breakdown to decide. Pulling container images from ECR through NAT on every scale-out is the case that usually justifies one.</p>



<h3 class="wp-block-heading">Log groups that never expire</h3>



<p class="wp-block-paragraph">CloudWatch Logs bills mostly on ingestion and secondarily on stored volume. A log group created without an explicit retention setting keeps data forever, so every debug group someone created during an incident years ago is still accruing storage.</p>



<pre class="wp-block-code"><code># Log groups with no retention policy set
aws logs describe-log-groups 
  --query 'logGroups[?!retentionInDays].[logGroupName,storedBytes]' 
  --output text

aws logs put-retention-policy 
  --log-group-name /aws/lambda/my-function 
  --retention-in-days 30</code></pre>



<p class="wp-block-paragraph">Retention is the safe half. Reducing ingestion is a tier-one change, because you are removing evidence you might need during an incident. If you need a long retention window cheaply, exporting to S3 with a lifecycle rule beats paying CloudWatch storage rates indefinitely, and a log platform like Grafana Loki or a hosted equivalent gives you a cheaper long tail while CloudWatch keeps the recent window.</p>



<h3 class="wp-block-heading">Incomplete multipart uploads</h3>



<p class="wp-block-paragraph">Failed large uploads leave parts behind in S3. Those parts are billed as storage and do not appear in a normal object listing, which is why some buckets bill for far more than they seem to contain. One lifecycle rule ends it:</p>



<pre class="wp-block-code"><code>{
  "Rules": [
    {
      "ID": "abort-incomplete-multipart",
      "Status": "Enabled",
      "Filter": {},
      "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
    }
  ]
}</code></pre>



<p class="wp-block-paragraph">Apply it with <code>aws s3api put-bucket-lifecycle-configuration</code>. That call replaces the entire lifecycle configuration on the bucket, so fetch the existing one and merge rather than overwrite.</p>



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



<h2 class="wp-block-heading">Tier one: savings that move a performance envelope</h2>



<p class="wp-block-paragraph">Now a wrong call has consequences. Everything here goes one workload at a time, with a named metric you watch for at least a full traffic cycle afterwards.</p>



<h3 class="wp-block-heading">gp2 to gp3, and the throughput trap</h3>



<p class="wp-block-paragraph">This is presented everywhere as a free win, and for most volumes it is. gp3 costs less per gibibyte and gives every volume a baseline of 3,000 IOPS regardless of size, where gp2 scales at 3 IOPS per GiB and only reaches 3,000 sustained at 1 TiB. For a small database volume quietly burning through burst credits, gp3 is a straight upgrade in both cost and stability.</p>



<p class="wp-block-paragraph">The trap is throughput, not IOPS. gp3&#8217;s baseline throughput is 125 MiB/s. A large gp2 volume can deliver up to 250 MiB/s. Migrate a big sequential-read volume to gp3 with the defaults and you halve its throughput ceiling while the cost report still shows a saving. Log processing, backup jobs and analytics scratch space are exactly the workloads that hit this, and exactly the ones where the symptom is &#8220;the nightly job got slower&#8221; rather than an alert.</p>



<p class="wp-block-paragraph">Check <code>VolumeReadBytes</code> and <code>VolumeWriteBytes</code> in CloudWatch first, and provision throughput explicitly where the volume needs it:</p>



<pre class="wp-block-code"><code>aws ec2 modify-volume --volume-id vol-0123456789abcdef0 
  --volume-type gp3 --iops 3000 --throughput 250

aws ec2 describe-volumes-modifications --volume-id vol-0123456789abcdef0 
  --query 'VolumesModifications[].[ModificationState,Progress]' 
  --output text</code></pre>



<p class="wp-block-paragraph">Elastic Volumes changes the type in place with the instance running, so there is no downtime. Two things to watch anyway. If the root volume is declared inline in a CloudFormation block device mapping, changing its type there can replace the instance rather than modify the volume; modify through the EC2 API instead. And there is a cooldown before a volume can be modified again, so plan on getting it right rather than iterating. On RDS the same change also runs without downtime, but the instance then enters a storage optimization state during which further storage changes are blocked.</p>



<h3 class="wp-block-heading">Rightsizing without guessing</h3>



<p class="wp-block-paragraph">AWS Compute Optimizer reads CloudWatch metrics and returns recommendations with a confidence rating. Better than eyeballing CPU graphs, with one important gap: without the CloudWatch agent installed it cannot see memory. A JVM service sitting at 12% CPU and 88% heap looks like an obvious downsize candidate and is not.</p>



<pre class="wp-block-code"><code>aws compute-optimizer get-ec2-instance-recommendations 
  --query 'instanceRecommendations[?finding==`OVER_PROVISIONED`].[instanceArn,currentInstanceType]' 
  --output text</code></pre>



<p class="wp-block-paragraph">My working rules:</p>



<ul class="wp-block-list">
<li>Look at a window that includes your worst week, not a rolling average. Averages hide the peak that justifies the size.</li>

<li>Change one dimension at a time, family or size, so you know what caused any regression.</li>

<li>Treat burstable families as a separate decision. A T-family instance that exhausts CPU credits under sustained load either throttles or silently bills you for unlimited mode. Neither is what you wanted.</li>

<li>Moving to Graviton usually saves more than downsizing x86, but it is an architecture change. Images need to be multi-arch and every compiled dependency needs to build. That belongs in a sprint, not in a cost-cutting afternoon.</li>

<li>Write down the metric you expect to move and the threshold at which you revert, before you make the change.</li>
</ul>



<h3 class="wp-block-heading">S3 storage classes and the small-object trap</h3>



<p class="wp-block-paragraph">Intelligent-Tiering is the low-thought option and it works well for what it was designed for: objects of meaningful size with access patterns you cannot predict. It charges a small per-object monitoring fee, moves objects between tiers automatically, and applies no retrieval fee on the automatic access tiers.</p>



<p class="wp-block-paragraph">Where it fails is object count. Objects below the eligibility threshold of 128 KB are not auto-tiered at all. They sit in the frequent access tier permanently while still paying monitoring. A bucket holding hundreds of millions of thumbnails, metadata records or small JSON documents can pay for monitoring that produces no saving whatsoever. Check average object size first, from S3 Storage Lens or an inventory report: total bytes divided by object count.</p>



<p class="wp-block-paragraph">Lifecycle transitions have their own trap, since each transition is a billed request. Moving a billion small objects to a colder class can cost more in transition requests than the storage saving returns in a year. Transitions pay off on size, not on count. Expiry is the exception: deleting costs nothing and removes the storage entirely. While you are in there, check non-current versions in versioned buckets, which accumulate invisibly and need their own rule. And if your workload is genuinely egress-heavy, an object store with no egress charge such as Cloudflare R2 changes the arithmetic enough to model, in exchange for cross-provider latency and an operational split.</p>



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



<h2 class="wp-block-heading">Tier two: commitments, which break budgets rather than uptime</h2>



<p class="wp-block-paragraph">Savings Plans and Reserved Instances cannot cause an outage. They are billing constructs. What they can do is hold you to a spend rate for one or three years after the architecture that justified it has changed. What I would want anyone to know before signing:</p>



<ul class="wp-block-list">
<li>Savings Plans cannot be cancelled during the term; AWS documentation is explicit. There is a limited return path with eligibility conditions and quotas per billing family, but plan as though there is no exit.</li>

<li>Standard Reserved Instances can be listed on the Reserved Instance Marketplace, which is a real exit but not a guaranteed one, since somebody has to buy. Convertible RIs cannot be sold, only exchanged for a different configuration.</li>

<li>Commit after rightsizing, never before. A plan applied to an oversized fleet locks in the waste and removes the incentive to fix it.</li>

<li>Savings Plans do not reserve capacity. If you need a guarantee that instances will be available in a specific zone, that is an On-Demand Capacity Reservation, a separate mechanism the discount can then apply to.</li>

<li>Savings Plans do not apply to Spot usage or to usage already covered by an RI. If much of your fleet is Spot, your committable baseline is smaller than the bill suggests.</li>

<li>Compute Savings Plans survive architectural drift: family changes, size changes and moves between EC2, Fargate and Lambda stay covered. EC2 Instance Savings Plans and Standard RIs discount more deeply and tolerate drift poorly. Choose based on how stable your architecture actually is, not how stable you would like it to be.</li>
</ul>



<p class="wp-block-paragraph">Most teams land on a layered shape: commit at the baseline you are confident will still exist in a year, leave the variable top of the curve on demand or on Spot. Flat usage and a frozen architecture justify deeper, longer commitments. Mid-migration, mid-container-adoption or mid-anything, take the smaller discount for the shorter term.</p>



<p class="wp-block-paragraph">One honest question worth asking here: does the workload need to be on AWS at all? Steady, non-elastic things like build runners, staging environments and internal tooling are often cheaper on a plain VPS from a provider such as Contabo or InterServer, while genuinely elastic workloads are often cheaper on AWS. The trade is operational, because you take back patching, backups and capacity planning that AWS was doing for you.</p>



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



<h2 class="wp-block-heading">Troubleshooting: the bill went down and something broke</h2>



<p class="wp-block-paragraph">The hard part of diagnosing a cost regression is the delay. The change lands on Tuesday, the symptom appears at month-end batch time, and by then three other things have shipped. Work backwards from the symptom:</p>



<ul class="wp-block-list">
<li><strong>Latency got worse but CPU is fine.</strong> Look at storage. <code>VolumeQueueLength</code> climbing with throughput flat at a round number means you are sitting on a provisioned ceiling, which is the gp3 default-throughput signature. On gp2, a falling <code>BurstBalance</code> is the tell, and it usually starts hours before anything is user-visible.</li>

<li><strong>Intermittent timeouts after rightsizing.</strong> Check whether the new instance type has lower network or EBS bandwidth, not just fewer vCPUs. Baseline bandwidth scales with size, and smaller sizes burst their network the way T instances burst CPU.</li>

<li><strong>Something stopped reaching a service.</strong> If you added VPC endpoints, check whether an endpoint policy is denying it. Interface endpoints also need the endpoint&#8217;s own security group to allow inbound from your workload, which is the most common reason a new endpoint appears to break connectivity.</li>

<li><strong>Objects are slow or missing from an application path.</strong> Check whether a lifecycle rule moved them into a class with retrieval latency. The Glacier tiers inside Intelligent-Tiering are opt-in for exactly this reason, and enabling them turns instant reads into restore requests.</li>
</ul>



<p class="wp-block-paragraph">Keep a change log for cost work specifically: a dated line per change, what you expected to save, what metric you were watching. Five minutes each, and it is the difference between diagnosing this in an hour and in a week.</p>



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



<ul class="wp-block-list">
<li><strong>Starting with the biggest number.</strong> Compute is the biggest line and the riskiest to touch. It is the last tier, not the first.</li>

<li><strong>Committing before rightsizing.</strong> This locks in the exact waste you were trying to remove, for years.</li>

<li><strong>Accepting gp3 defaults on high-throughput volumes.</strong> Cheaper storage, halved throughput ceiling, no alert.</li>

<li><strong>Enabling Intelligent-Tiering on buckets full of tiny objects.</strong> Monitoring fees on objects that will never move a tier.</li>

<li><strong>Turning off monitoring to save on monitoring.</strong> Understandable at 2am on a budget deadline, indefensible during the next incident. Cut cardinality and retention, not coverage.</li>

<li><strong>Doing it once.</strong> Cost drifts back within two quarters unless something structural stops it.</li>
</ul>



<h2 class="wp-block-heading">Best practices that make the savings stick</h2>



<ul class="wp-block-list">
<li><strong>Make cost visible before it is a crisis.</strong> AWS Budgets with alerts at a percentage of forecast, plus Cost Anomaly Detection, catch the runaway job on day two rather than on the invoice. Platforms like Vantage, CloudZero or Kubecost for per-namespace Kubernetes attribution earn their place once several teams share an account structure.</li>

<li><strong>Tag at creation, in the IaC.</strong> Tags applied afterwards do not backfill historical cost data. If it was not tagged when it was created, that spend is unattributable forever.</li>

<li><strong>Put non-production on a schedule.</strong> Dev and staging running nights and weekends is the most common waste in any account, and stopping them has no production blast radius at all.</li>

<li><strong>Make the cheap thing the default in the module.</strong> gp3, sensible log retention, gateway endpoints and lifecycle rules belong in your Terraform or OpenTofu modules, so the next environment is born correct rather than remediated later.</li>

<li><strong>Review commitment coverage on a schedule.</strong> Coverage and utilisation are different numbers and both matter. High coverage with low utilisation means you over-committed.</li>
</ul>



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



<h3 class="wp-block-heading">What is the fastest way to cut AWS costs without breaking production?</h3>



<p class="wp-block-paragraph">Add gateway endpoints for S3 and DynamoDB, delete unassociated Elastic IPs and unattached EBS volumes, and set retention on log groups that have none. All four are billing-only changes with no behavioural effect on your application, and together they often account for a large share of the surprise on a bill.</p>



<h3 class="wp-block-heading">Why is my &#8220;EC2 &#8211; Other&#8221; line item so large?</h3>



<p class="wp-block-paragraph">It is a mixed bucket: EBS volumes and snapshots, NAT Gateway hours and data processing, data transfer, and public IPv4 address charges. Regroup Cost Explorer by usage type rather than by service to see which of those is driving it.</p>



<h3 class="wp-block-heading">Is migrating from gp2 to gp3 always cheaper?</h3>



<p class="wp-block-paragraph">No. gp3 has a lower per-gibibyte price, but IOPS above the included baseline and throughput above the included baseline are billed separately. A volume needing high provisioned throughput can cost more on gp3 than it did on gp2. Model the volumes that genuinely need extra performance instead of assuming a flat saving across the fleet.</p>



<h3 class="wp-block-heading">Can I cancel a Savings Plan if my usage drops?</h3>



<p class="wp-block-paragraph">Assume no. AWS documents Savings Plans as non-cancellable for the term. There is a limited return mechanism with eligibility rules and quotas, and no secondary marketplace of the kind that exists for Standard Reserved Instances. Size the commitment to the baseline you are confident about, not the peak you hope for.</p>



<h3 class="wp-block-heading">Should I use Spot Instances to cut costs?</h3>



<p class="wp-block-paragraph">For fault-tolerant, interruptible work such as CI runners, batch jobs and stateless workers behind a queue, the discount is hard to ignore. For anything holding state in memory or serving a request that cannot be retried, the interruption notice is short and the failure is abrupt. Spot is an architecture decision, not a billing toggle.</p>



<h3 class="wp-block-heading">How do I find out where my NAT Gateway traffic is going?</h3>



<p class="wp-block-paragraph">Billing data cannot tell you. Enable VPC Flow Logs on the relevant subnets, query them with Athena or CloudWatch Logs Insights grouped by destination address, and map the results back to AWS service ranges. That gives you the split between S3, ECR, other AWS services and the genuine internet, which is what determines whether an endpoint pays for itself.</p>



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



<p class="wp-block-paragraph">If you take one idea from this, take the ordering. You cut AWS costs without breaking production by sorting the work by blast radius rather than by savings size, and by accepting that the frightening-looking networking and storage line items are usually the safe ones, while the innocuous single-API-call changes are the ones that bite.</p>



<p class="wp-block-paragraph">Do the tier-zero sweep first and completely. It is free, it is fast, and it buys you the standing to say &#8220;not yet&#8221; when someone asks you to shrink a production database on a Thursday. Then move one performance envelope at a time, with a metric and a revert threshold. Commit last, at the baseline you actually believe in.</p>



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



<p class="wp-block-paragraph">This is work I do regularly, and it goes faster with someone who has no attachment to how the account got this way. Things I can help with:</p>



<ul class="wp-block-list">
<li>A usage-type level breakdown of your bill, with savings ranked by blast radius, so you know what is safe this week and what needs a change window.</li>

<li>NAT Gateway and data transfer analysis from VPC Flow Logs, ending in a specific list of which endpoints to add and what each is worth.</li>

<li>EBS and S3 review: gp3 candidates with throughput checked properly, lifecycle rules, version cleanup, and storage class decisions based on your real object size distribution.</li>

<li>CloudWatch and logging spend reduction that keeps the signal you need during incidents, including moving long-tail retention off CloudWatch storage rates.</li>

<li>Savings Plan and Reserved Instance modelling against your actual baseline, including what happens to coverage if the migration you are planning goes ahead.</li>

<li>Guardrails so it stays fixed: Budgets, anomaly alerting, tagging enforced in Terraform or OpenTofu, and cheap defaults baked into your modules.</li>
</ul>



<p class="wp-block-paragraph">If you want a concrete starting point, send me a Cost Explorer export grouped by usage type, or the output of the orphan sweep commands above, and I will tell you what I would touch first and what I would leave alone.</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/cut-aws-costs-without-breaking-production/">Cut AWS Costs Without Breaking Production: A Blast-Radius Playbook</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>CloudWatch Alarms That Are Actually Useful (and the Ones to Delete)</title>
		<link>https://john-nessime.com/blog/devops/useful-cloudwatch-alarms/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 26 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Alarm Fatigue]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Amazon SNS]]></category>
		<category><![CDATA[Anomaly Detection]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Composite Alarms]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[Metric Math]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[On-Call]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Terraform]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=266</guid>

					<description><![CDATA[<p>Most AWS accounts have dozens of CloudWatch alarms and about three anyone trusts. A practical guide to missing-data settings, M-out-of-N evaluation, metric math, composite alarms and mute rules, and how to test the notification path before you need it.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/useful-cloudwatch-alarms/">CloudWatch Alarms That Are Actually Useful (and the Ones to Delete)</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 CPU alarm has been green for six weeks. That is not because the instance is healthy. It is because the agent stopped publishing the metric, the alarm quietly slid into INSUFFICIENT_DATA, and nobody ever wired that state to anything. Green on the dashboard, deaf in production.</p>



<p class="wp-block-paragraph">That is the failure mode nobody catches during a review, and it is why most AWS accounts have dozens of alarms and about three anyone trusts. The rest either fire constantly and get muted, or never fire at all and get mistaken for coverage.</p>



<p class="wp-block-paragraph">This post is about building useful CloudWatch alarms: which signals deserve a page, which settings decide whether an alarm fires correctly, how to collapse alarm storms without going blind, and how to prove the notification path works before you need it.</p>



<h2 class="wp-block-heading">The test an alarm has to pass before it exists</h2>



<p class="wp-block-paragraph">Before the threshold, before the metric, one question: if this fires at 3am, what does the person on call do?</p>



<p class="wp-block-paragraph">If the honest answer is &#8220;look at it and go back to bed&#8221;, it is not an alarm. It is a dashboard panel. That single filter removes most of the CPU, memory and disk alarms people inherit. High CPU is not an incident. High CPU while latency climbs and the queue backs up is an incident. Every alarm that fires without requiring action trains the on-call to ignore the channel, and that training is permanent.</p>



<ul class="wp-block-list">
<li><strong>Page</strong> on symptoms a customer can feel: error rate, latency at a high percentile, queue age, failed jobs, expiring credentials.</li>

<li><strong>Ticket</strong> on causes that bite next week: disk trending toward full, approaching a service quota, a renewal that is not happening.</li>

<li><strong>Graph only</strong> on everything else. Resource utilisation is context during an incident, not the incident.</li>
</ul>



<p class="wp-block-paragraph">Encode that split in the alarm&#8217;s action, not in your head. A page goes to an SNS topic wired to PagerDuty or Opsgenie. A ticket goes to a different topic that opens a Jira issue or a Systems Manager OpsItem. Same engine, different consequences.</p>



<h2 class="wp-block-heading">The alarm that goes quiet: missing data is the invisible failure</h2>



<p class="wp-block-paragraph">Every metric alarm has a <code>TreatMissingData</code> setting, and if you never touched it, it is <code>missing</code>. That default means the alarm ignores gaps entirely when deciding state. It keeps whatever state it had, or sits in INSUFFICIENT_DATA if there is nothing at all. For a metric that always reports, that is a silent hole: the instance dies, the metric stops, and the alarm does not go red. It just stops having an opinion.</p>



<ul class="wp-block-list">
<li><code>breaching</code> for metrics that report continuously. If the data stops, something is wrong. Heartbeats, health checks, CPU on a box that should always be up.</li>

<li><code>notBreaching</code> for metrics that only exist when something bad happens. Error counts, throttled requests, dead letter queue depth. No data means no errors.</li>

<li><code>ignore</code> keeps the current state through gaps, so an alarm stays red until genuinely resolved rather than flapping to OK on a hole in the data.</li>

<li><code>missing</code> is the default, and defensible only when you decided it was right.</li>
</ul>



<p class="wp-block-paragraph">There is a second-order effect that surprises people. When an alarm evaluates, CloudWatch pulls a wider range of data points than the evaluation periods you configured. AWS calls that the evaluation range. If some points in that range are missing but enough real ones exist, CloudWatch evaluates against the real points and your missing-data setting never applies to the gaps.</p>



<p class="wp-block-paragraph">The consequence: an alarm set to eight breaching points out of ten can fire on one breaching point followed by nine gaps, because the older breach is still inside the evaluation range and everything newer is absent. Teams hit this with load balancer 5XX alarms constantly, and it looks exactly like an unexplained false positive.</p>



<p class="wp-block-paragraph">Setting <code>notBreaching</code> on sparse error metrics is usually the fix. Filling gaps with metric math is the other, and it is more explicit:</p>



<pre class="wp-block-code"><code># Treat absent error data points as zero rather than as gaps.
# m1 is the raw error metric; e1 is what the alarm evaluates.
e1 = FILL(m1, 0)</code></pre>



<p class="wp-block-paragraph">One exception: alarms on metrics in the <code>AWS/DynamoDB</code> namespace always ignore missing data regardless of what you set. If you need a DynamoDB alarm that reacts to silence, build the fill into metric math.</p>



<p class="wp-block-paragraph">Then find the ones already stuck. This is the fastest audit you can run on an inherited account:</p>



<pre class="wp-block-code"><code>aws cloudwatch describe-alarms 
  --state-value INSUFFICIENT_DATA 
  --query 'MetricAlarms[].[AlarmName,MetricName,StateReason]' 
  --output table</code></pre>



<p class="wp-block-paragraph">Anything on that list older than a deploy cycle is either watching something that no longer exists, or watching something that stopped reporting. Both deserve thirty seconds.</p>



<h2 class="wp-block-heading">The alarm that cries wolf: periods, data points and windows</h2>



<p class="wp-block-paragraph">Three settings decide whether an alarm is jumpy: <code>Period</code> (how long each data point covers), <code>EvaluationPeriods</code> (how many recent points to look at), and <code>DatapointsToAlarm</code> (how many of those must breach).</p>



<h3 class="wp-block-heading">M out of N beats consecutive breaches</h3>



<p class="wp-block-paragraph">Most people set evaluation periods to 3 and leave data points to alarm unset, which makes it 3 as well. That demands three consecutive breaching points. Real degradation is rarely that tidy; it flickers, one clean point resets your patience, and the alarm never fires.</p>



<p class="wp-block-paragraph">Set them separately. Three out of five catches intermittent problems while still filtering single-point spikes. The breaching points do not need to be consecutive, only inside the last N.</p>



<pre class="wp-block-code"><code>aws cloudwatch put-metric-alarm 
  --alarm-name "api-5xx-elevated" 
  --namespace "AWS/ApplicationELB" 
  --metric-name HTTPCode_ELB_5XX_Count 
  --dimensions Name=LoadBalancer,Value=app/my-alb/0123456789abcdef 
  --statistic Sum 
  --period 60 
  --evaluation-periods 5 
  --datapoints-to-alarm 3 
  --threshold 25 
  --comparison-operator GreaterThanThreshold 
  --treat-missing-data notBreaching 
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:oncall-page</code></pre>



<p class="wp-block-paragraph">Read that as: over the last five minutes, if three or more individual minutes each saw more than 25 backend 5XX responses, page. Minutes with no data count as fine, because a load balancer with no errors publishes nothing.</p>



<h3 class="wp-block-heading">Percentiles need a sample-count decision</h3>



<p class="wp-block-paragraph">Average latency hides everything interesting, so alarm on <code>p95</code> or <code>p99</code> using the extended statistic rather than <code>--statistic Average</code>.</p>



<p class="wp-block-paragraph">The catch is low traffic. A p99 computed from four requests is noise, and by default CloudWatch still evaluates it. The <code>EvaluateLowSampleCountPercentile</code> setting controls that: set it to <code>ignore</code> and the alarm holds its current state when the sample count is too small to mean anything. On an overnight window or a quiet endpoint, that one setting removes a whole class of 4am pages.</p>



<h3 class="wp-block-heading">Sliding versus wall clock windows</h3>



<p class="wp-block-paragraph">By default the evaluation window slides: it advances a minute at a time and is not aligned to the clock. That is what you want for latency and errors, because you want to know now.</p>



<p class="wp-block-paragraph">CloudWatch also supports a wall clock window aligned to real boundaries in a time zone you pick: top of the hour, midnight, start of the calendar week. That is the right choice for anything measured per calendar period, like a daily batch job or a monthly threshold, because the alarm considers a completed period rather than a rolling one. Note that the wider evaluation range described earlier applies only to sliding windows, so wall clock alarms are easier to reason about but have no safety net for late-arriving data.</p>



<h2 class="wp-block-heading">Alarming on the wrong number</h2>



<p class="wp-block-paragraph">A large share of useless alarms are correctly configured alarms watching a meaningless quantity.</p>



<h3 class="wp-block-heading">Rates, not counts</h3>



<p class="wp-block-paragraph">An alarm on &#8220;more than 50 errors&#8221; is wrong twice. During a traffic peak, 50 errors out of half a million requests is nothing. At 3am, 50 errors out of 60 requests is a total outage that never crosses the threshold.</p>



<pre class="wp-block-code"><code># m1 = Errors (Sum), m2 = Invocations (Sum)
# Guard the zero case so the alarm gets a data point at idle
# instead of a gap it has to interpret.
e1 = IF(m2 &gt; 0, m1 / m2 * 100, 0)</code></pre>



<p class="wp-block-paragraph">Only the final expression should return data to the alarm; the underlying metrics are inputs. A rate alarm alone is still incomplete, though, because at very low volume one error is 100 percent. Pair it with a minimum-volume condition in a composite alarm, or fold the request count into the expression so tiny denominators cannot trip it.</p>



<h3 class="wp-block-heading">Quotas and saturation, before they bite</h3>



<p class="wp-block-paragraph">Service quotas are the outage nobody sees coming, because everything is healthy right up until it is not. For services that publish usage metrics, the <code>SERVICE_QUOTA</code> metric math function pulls your current limit from Service Quotas, so you can alarm on percentage consumed instead of a number you hardcoded and forgot:</p>



<pre class="wp-block-code"><code># m1 is a usage metric under the Usage namespace.
# Alarm on consumption as a share of the live quota.
e1 = m1 / SERVICE_QUOTA(m1) * 100</code></pre>



<p class="wp-block-paragraph">This is the definitive ticket-not-page alarm. Nobody needs waking for concurrency at 80 percent, but somebody needs to file a limit increase before the deploy that takes it to 100. Same category: queue age rather than queue depth. A deep queue draining fast is fine; a shallow queue whose oldest message is twenty minutes old means your consumers are stuck.</p>



<h2 class="wp-block-heading">Cutting the storm: composite alarms, suppression and mute rules</h2>



<p class="wp-block-paragraph">One bad deploy in an Auto Scaling group with per-instance alarms produces forty notifications describing one event. A composite alarm evaluates a boolean rule over other alarms. The metric alarms stay exactly as they are and keep their own state, but stop being wired to your paging topic. The composite becomes the only thing that pages.</p>



<pre class="wp-block-code"><code>aws cloudwatch put-composite-alarm 
  --alarm-name "checkout-degraded" 
  --alarm-rule 'ALARM("checkout-latency-p99") AND ALARM("checkout-error-rate")' 
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:oncall-page</code></pre>



<p class="wp-block-paragraph">Latency alone might be a slow dependency. Errors alone might be a bad client. Both together is a real problem, and that is what earns a page.</p>



<p class="wp-block-paragraph">The second half is suppression. A composite alarm accepts a suppressor alarm: while that alarm is in ALARM, the composite still evaluates but takes no actions. Point it at a deployment-in-progress alarm and you stop paging for known-bad windows.</p>



<pre class="wp-block-code"><code>aws cloudwatch put-composite-alarm 
  --alarm-name "checkout-degraded" 
  --alarm-rule 'ALARM("checkout-latency-p99") AND ALARM("checkout-error-rate")' 
  --actions-suppressor "deployment-in-progress" 
  --actions-suppressor-wait-period 60 
  --actions-suppressor-extension-period 60 
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:oncall-page</code></pre>



<p class="wp-block-paragraph">Those two periods are the part people skip and then get burned by. The wait period gives the suppressor time to reach ALARM before the composite decides to fire; the extension period gives the composite time to settle back to OK after the suppressor clears. AWS suggests 60 seconds for each, on the reasoning that metric alarms evaluate once a minute. Leave them at zero and you get paged in the gap between the deploy starting and the deploy alarm noticing.</p>



<p class="wp-block-paragraph">CloudWatch has since added alarm mute rules, which handle the scheduled case directly. A mute rule targets specific alarms by name and mutes their actions during a one-time or recurring window. The alarms keep evaluating and keep changing state; only the actions are muted. That removes the EventBridge and Lambda contraption teams used to build for maintenance windows. Two things to keep straight: mute rules take precedence over composite action suppression when both apply, and a mute rule targets alarms by name, so a rename in your Terraform or OpenTofu module silently orphans the rule.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Suppression is for known-bad windows you chose. If you are muting an alarm because it is annoying rather than because you scheduled the disruption, the alarm is wrong and muting it just hides that.</p>
</blockquote>



<h2 class="wp-block-heading">Test the path, not the threshold</h2>



<p class="wp-block-paragraph">An alarm you have never seen fire is a hypothesis. The threshold is the easy part. The fragile part is everything downstream: SNS subscriptions that were never confirmed, a Lambda whose role lost a permission, a chat webhook rotated three months ago, a topic policy that quietly rejects CloudWatch.</p>



<p class="wp-block-paragraph">You can force a state transition without touching the metric:</p>



<pre class="wp-block-code"><code>aws cloudwatch set-alarm-state 
  --alarm-name "api-5xx-elevated" 
  --state-value ALARM 
  --state-reason "Routing test, ticket OPS-1234"</code></pre>



<p class="wp-block-paragraph">This drives the real actions, so the page really goes out. Tell the on-call first. The forced state is temporary; the next real evaluation overwrites it. Do this for every new alarm before you call it done, and again after any change to the notification stack. On a mixed estate where some workloads sit on a VPS at a provider like Contabo or InterServer alongside your AWS footprint, test each path separately, because a shared SNS topic creates the illusion that one confirmed subscription covers everything.</p>



<p class="wp-block-paragraph">Then confirm the transition and the action both happened:</p>



<pre class="wp-block-code"><code>aws cloudwatch describe-alarm-history 
  --alarm-name "api-5xx-elevated" 
  --history-item-type Action 
  --max-records 10</code></pre>



<h2 class="wp-block-heading">What useful CloudWatch alarms cost you</h2>



<p class="wp-block-paragraph">Alarms bill per alarm metric per month, so the mental model that matters is this: a standard metric alarm counts as one, and a metric math alarm counts as one per metric referenced in the expression. An error-rate alarm built from errors and invocations bills as two, not one. Anomaly detection alarms carry their own multiplier because the band is evaluated alongside the metric. High-resolution alarms, with periods of 10, 20 or 30 seconds, are charged at a higher rate than standard ones, which makes them worth it for a handful of latency-critical paths and wasteful everywhere else.</p>



<p class="wp-block-paragraph">Check current rates on the CloudWatch pricing page rather than trusting a number in a blog post, including this one. And note the bigger cost is usually not the alarm at all. It is the custom metrics feeding it, especially high-cardinality ones published per request or per container.</p>



<h2 class="wp-block-heading">Troubleshooting an alarm that misbehaves</h2>



<ol class="wp-block-list">
<li><strong>Stuck in INSUFFICIENT_DATA.</strong> Either the metric is not being published, or the alarm&#8217;s dimensions do not match the metric&#8217;s dimensions exactly. Dimension mismatch is the common one and it is silent: an alarm on a dimension set that never existed looks identical to an alarm on a dead resource.</li>

<li><strong>Fired but nobody heard.</strong> Check <code>describe-alarm-history</code> filtered to Action items. Transition present but action missing points at the SNS topic policy or the subscription. Both present points downstream of SNS.</li>

<li><strong>Fires with no visible breach on the graph.</strong> Almost always missing data plus the wider evaluation range. The state reason names how many points were breaching and how many were unknown.</li>

<li><strong>Flapping between OK and ALARM.</strong> The period is too short for the metric&#8217;s natural variance, or data points to alarm equals evaluation periods. Widen the window before raising the threshold; raising the threshold loses sensitivity you may want later.</li>

<li><strong>Composite alarm never fires.</strong> Confirm every child alarm named in the rule exists and is spelled correctly, then confirm no suppressor or mute rule is active. The console shows a mute indicator on affected alarms.</li>
</ol>



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



<ul class="wp-block-list">
<li>Leaving <code>TreatMissingData</code> at the default because you did not know it existed.</li>

<li>Picking 80 percent CPU because it sounds like a threshold, on a service that normally runs at 75.</li>

<li>Alarming per instance in an Auto Scaling group instead of on the aggregate a customer experiences.</li>

<li>Wiring OK actions to the same paging channel, so every recovery is also an interruption.</li>

<li>Alarm descriptions that restate the alarm name. The description is the only context the responder gets at 3am, so put the runbook link there.</li>

<li>Anomaly detection on a metric with no stable pattern. The band is only as good as the history it learned from.</li>
</ul>



<h2 class="wp-block-heading">Best practices for useful CloudWatch alarms</h2>



<ul class="wp-block-list">
<li>Set <code>TreatMissingData</code> explicitly on every alarm, and write down why in the description.</li>

<li>Separate <code>DatapointsToAlarm</code> from <code>EvaluationPeriods</code>. M out of N is almost always better than N consecutive.</li>

<li>Observe a metric across a full weekly cycle before fixing its threshold. Weekend traffic is a different distribution.</li>

<li>Define alarms in code, in whatever you already use: Terraform, OpenTofu, CDK, CloudFormation. Console-created alarms drift and disappear.</li>

<li>Use anomaly detection for the signal and composite alarms for the logic. An anomalous shape plus real user impact is a page; an anomalous shape alone is a graph.</li>

<li>Review what fired last quarter. Any alarm that fired and was closed without action is a candidate for deletion. Send it to a Grafana panel instead.</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">How many CloudWatch alarms should a service have?</h3>



<p class="wp-block-paragraph">Fewer than you think. A handful of symptom alarms that page, plus a set of cause alarms that open tickets, covers most services. The number that matters is not how many exist but how many fired last quarter and led to somebody doing something.</p>



<h3 class="wp-block-heading">Should I use anomaly detection or static thresholds?</h3>



<p class="wp-block-paragraph">Static thresholds where a real limit exists: a disk that must not fill, a latency budget you promised. Anomaly detection where the metric has a genuine daily or weekly shape and no fixed correct value, like request volume. Anomaly detection needs history before it is useful and struggles when a deploy changes the pattern, so it is a poor fit for anything new.</p>



<h3 class="wp-block-heading">Why did my alarm fire when the graph looks fine?</h3>



<p class="wp-block-paragraph">Usually missing data. CloudWatch pulls a wider evaluation range than the periods you configured, so an older breaching point can still count while newer points are simply absent. Read the state reason, which spells out how many points were breaching and how many were unknown, then reconsider the missing-data setting.</p>



<h3 class="wp-block-heading">Can an alarm watch a metric in another AWS account?</h3>



<p class="wp-block-paragraph">Cross-account observability lets a monitoring account view and alarm on metrics from linked source accounts, which is the usual pattern for an AWS Organizations estate. The alternative is running alarms in each account and aggregating at the SNS or paging layer. That is more alarms to manage but keeps the blast radius per account smaller.</p>



<h3 class="wp-block-heading">How do I stop alarms firing during a deployment?</h3>



<p class="wp-block-paragraph">Two options. An alarm mute rule on a schedule, if your deploys are predictable. Or a composite alarm with a deployment-in-progress alarm as its actions suppressor, if they are not. The suppressor approach suits continuous delivery better because it reacts to the actual deploy rather than a calendar guess.</p>



<h3 class="wp-block-heading">Do CloudWatch alarms work on logs?</h3>



<p class="wp-block-paragraph">Indirectly. A metric filter on a log group turns matching log lines into a numeric metric, and you alarm on that metric like any other. Metric filters only emit data points when they match, so <code>notBreaching</code> is almost always the right missing-data setting for them.</p>



<h3 class="wp-block-heading">Is CloudWatch enough, or do I need Datadog or Grafana?</h3>



<p class="wp-block-paragraph">For a single-account AWS workload, CloudWatch alarms plus SNS handle alerting fine, and staying inside AWS avoids another integration to maintain. Third-party platforms earn their keep when you need correlation across non-AWS systems, richer on-call routing, or long metric retention with fast queries. The honest test is whether you are already spending engineering time rebuilding those features on top of CloudWatch.</p>



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



<p class="wp-block-paragraph">Useful CloudWatch alarms are not the ones with the cleverest thresholds. They are the ones where somebody decided, on purpose, what silence means.</p>



<p class="wp-block-paragraph">Every alarm in your account is making a claim about the absence of data, whether you configured that claim or inherited it. Go through them, set <code>TreatMissingData</code> deliberately, split data points from evaluation periods, delete the ones nobody acts on, and force a state transition on the ones you keep so you know the page lands. Six alarms that fire correctly beat sixty that nobody reads.</p>



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



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



<p class="wp-block-paragraph">I work with teams on AWS monitoring that people actually trust. Typically that looks like:</p>



<ul class="wp-block-list">
<li>Auditing existing CloudWatch alarms and separating the ones that page, the ones that should open tickets, and the ones to delete.</li>

<li>Fixing missing-data and evaluation settings so alarms stop firing on gaps and stop going silent on dead metrics.</li>

<li>Building composite alarms, suppression and mute rules so one incident produces one page instead of forty.</li>

<li>Writing metric math for error rates, queue age and quota headroom instead of raw counts.</li>

<li>Codifying the whole alarm set in Terraform or OpenTofu so it survives the next person.</li>

<li>Wiring CloudWatch into Grafana dashboards and testing every notification path end to end.</li>
</ul>



<p class="wp-block-paragraph">If you want a concrete starting point, send me the output of <code>describe-alarms</code> for one account and I will tell you which alarms are load-bearing and which are decoration.</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/useful-cloudwatch-alarms/">CloudWatch Alarms That Are Actually Useful (and the Ones to Delete)</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Monitoring n8n Workflow Failures in Grafana: Why Your Green Dashboard Is Lying</title>
		<link>https://john-nessime.com/blog/devops/monitoring-n8n-workflow-failures-grafana/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 25 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Cardinality]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[n8n]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[PromQL]]></category>
		<category><![CDATA[Pushgateway]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Webhooks]]></category>
		<category><![CDATA[Workflow Automation]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=276</guid>

					<description><![CDATA[<p>n8n's Prometheus endpoint tells you the process is alive and busy. It does not tell you a workflow stopped producing correct output. This post walks through the three failure families that slip past a default n8n dashboard, the PromQL that catches each one, and how to close the gap with a second source of truth from the executions API.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/monitoring-n8n-workflow-failures-grafana/">Monitoring n8n Workflow Failures in Grafana: Why Your Green Dashboard Is Lying</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone asks why the nightly invoice sync hasn&#8217;t posted anything since Tuesday. You open Grafana. The n8n scrape target is UP, the instance has been running for weeks without a restart, every panel is green. Nothing on the dashboard says a thing is wrong, and yet four days of records are missing.</p>



<p class="wp-block-paragraph">That gap is the entire problem. Monitoring n8n workflow failures in Grafana is not hard because the tooling is missing. It&#8217;s hard because the built-in metrics endpoint answers a question you didn&#8217;t ask. It tells you the process is alive and busy. It does not tell you that a workflow stopped producing correct output.</p>



<p class="wp-block-paragraph">This post covers what n8n&#8217;s Prometheus endpoint actually exposes, the three families of failure it handles badly, and the specific PromQL and alert rules that close each gap. There&#8217;s also a troubleshooting section for the case where <code>/metrics</code> returns nothing at all, which is the first wall most people hit.</p>



<h2 class="wp-block-heading">What the n8n metrics endpoint actually gives you</h2>



<p class="wp-block-paragraph">n8n uses the <code>prom-client</code> library and serves metrics on the same port as the editor, at <code>/metrics</code>. The endpoint is off by default, and almost everything interesting inside it is off by default too, behind its own environment variable.</p>



<pre class="wp-block-code"><code>N8N_METRICS=true
N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS=true
N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL=true
N8N_METRICS_INCLUDE_NODE_TYPE_LABEL=true
N8N_METRICS_INCLUDE_QUEUE_METRICS=true</code></pre>



<p class="wp-block-paragraph">The first line opens the endpoint. The second turns on the workflow and node event counters, which is the part people assume is included and is not. The third attaches a <code>workflow_id</code> label to workflow metrics, without which you get a single instance-wide number and no way to tell which automation broke.</p>



<p class="wp-block-paragraph">Those labels are opt-in for a reason worth understanding rather than working around. Every distinct label value creates a separate time series. Turn on <code>workflow_id</code> and you get one series per workflow per counter; add <code>node_type</code> and node counters fan out across every integration you use. On a few dozen workflows that&#8217;s fine. On several hundred with per-node labels, you&#8217;re signing up for a cardinality problem that surfaces later as slow queries and a Prometheus process that keeps getting OOM-killed.</p>



<p class="wp-block-paragraph">With the event bus metrics enabled, the counters you get include <code>n8n_workflow_started_total</code>, <code>n8n_workflow_success_total</code>, <code>n8n_node_started_total</code> and <code>n8n_node_finished_total</code>. Queue mode adds <code>n8n_scaling_mode_queue_jobs_active</code>, <code>_completed</code>, <code>_failed</code> and <code>_waiting</code>, gathered from Bull and exposed on the main instance only.</p>



<p class="wp-block-paragraph">Prometheus side, the scrape job is unremarkable:</p>



<pre class="wp-block-code"><code>scrape_configs:
  - job_name: n8n
    static_configs:
      - targets:
          - n8n-main:5678
          - n8n-worker-1:5678
    metrics_path: /metrics</code></pre>



<p class="wp-block-paragraph">Note both main and workers. Each process exposes its own endpoint and its own counters. Scrape only the main and you lose everything the workers did, which in queue mode is nearly all the real execution work.</p>



<p class="wp-block-paragraph">One thing the n8n docs are emphatic about, and they&#8217;re right: don&#8217;t put <code>/metrics</code> on the public internet. It leaks workflow IDs, execution volumes and instance topology to anyone who asks. Keep the scrape path on a private network segment, a WireGuard tunnel, or your provider&#8217;s internal networking. If you&#8217;re running the whole stack on a single VPS from somewhere like Contabo or InterServer, binding n8n to the Docker network and scraping it by container name is enough. Publishing port 5678 to the host and firewalling it afterwards is not, because you will forget.</p>



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



<h2 class="wp-block-heading">Failure family one: the execution that never happened</h2>



<p class="wp-block-paragraph">This is the one that costs the most and shows up the least. A schedule trigger gets deactivated during a deploy. A credential expires and the workflow is switched off rather than fixed. An upstream service stops calling a webhook. Nothing errors. Nothing increments. The workflow simply stops existing as far as your metrics are concerned.</p>



<p class="wp-block-paragraph">Every error-rate alert you write is blind to this, because an error rate needs a denominator. Zero executions divided by zero executions is not an alert, it&#8217;s a NaN.</p>



<p class="wp-block-paragraph">The instinct is to write this:</p>



<pre class="wp-block-code"><code>sum by (workflow_id) (increase(n8n_workflow_started_total[6h])) == 0</code></pre>



<p class="wp-block-paragraph">It looks correct and it does not work. When a workflow stops running, its series stops being exported. Prometheus marks it stale after a few missed scrapes and instant queries stop returning it. There is no series left for <code>== 0</code> to match against. The alert never fires, because the thing you wanted to alert on is exactly the thing that removed the data.</p>



<p class="wp-block-paragraph">What you want is a function that returns something when a series is absent:</p>



<pre class="wp-block-code"><code>absent_over_time(
  n8n_workflow_started_total{workflow_id="AbC123XyZ"}[6h]
)</code></pre>



<p class="wp-block-paragraph"><code>absent_over_time</code> returns 1 if the matched series produced no samples in the window and nothing at all if it did. That inversion is the point. The alert fires on silence.</p>



<p class="wp-block-paragraph">The cost is that <code>absent_over_time</code> needs an exact matcher, so you can&#8217;t write it once for everything. That&#8217;s more acceptable than it sounds. You don&#8217;t need a heartbeat on all two hundred workflows, you need one on the six a human would notice within an hour. Write those six by hand and treat the list as part of the runbook.</p>



<p class="wp-block-paragraph">One Grafana-specific trap. When a query returns nothing, Grafana&#8217;s alert rule enters a <strong>No Data</strong> state, which is configurable separately from the alert condition and defaults to firing in most versions. Set it deliberately. For an <code>absent_over_time</code> rule, No Data means the whole scrape target is gone, which is a different incident from one workflow going quiet, and you probably want it routed differently.</p>



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



<h2 class="wp-block-heading">Failure family two: the failure you can see but can&#8217;t attribute</h2>



<p class="wp-block-paragraph">You have the counters. Something is failing. Which workflow?</p>



<p class="wp-block-paragraph">Without <code>N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL</code>, the answer is: no idea. The counters are instance-wide totals. You know the number moved and that&#8217;s it. That&#8217;s a fine signal for capacity planning and useless during an incident.</p>



<p class="wp-block-paragraph">Turn the label on and you get IDs, which are opaque strings like <code>AbC123XyZ</code>. A dashboard full of those is technically attributable and practically unreadable at 3am. Newer n8n versions solve this with a gauge that maps IDs to names, enabled with <code>N8N_METRICS_INCLUDE_WORKFLOW_INFO</code>:</p>



<pre class="wp-block-code"><code>n8n_workflow_info{workflow_id="VaQPuPmx9tPpo6BX",workflow_name="Invoice sync"} 1</code></pre>



<p class="wp-block-paragraph">It&#8217;s a constant-1 gauge that exists only to be joined against. The pattern is a standard Prometheus vector match:</p>



<pre class="wp-block-code"><code>sum by (workflow_name) (
  rate(n8n_workflow_started_total[5m])
  * on(workflow_id) group_left(workflow_name)
  n8n_workflow_info
)</code></pre>



<p class="wp-block-paragraph">The multiplication is arithmetically a no-op because the gauge is always 1. Its only job is to carry the <code>workflow_name</code> label across with <code>group_left</code>. Do this once in a Grafana variable or a recording rule rather than in every panel, or you&#8217;ll be copy-pasting the join for the rest of your life.</p>



<p class="wp-block-paragraph">On an older build without the info gauge, the fallback is a hand-maintained relabel config or a Grafana value mapping. Both drift, and the relabel version drifts silently.</p>



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



<h2 class="wp-block-heading">Failure family three: the failure that counts as a success</h2>



<p class="wp-block-paragraph">This is the invisible one, and it&#8217;s why a green n8n dashboard is not evidence of anything.</p>



<p class="wp-block-paragraph">n8n gives you several deliberate ways to make a node&#8217;s failure not be an execution failure. They&#8217;re all reasonable features. Together they mean the success counter and &#8220;the automation worked&#8221; are different statements.</p>



<ul class="wp-block-list">
<li><strong>Continue on error.</strong> A node set to continue passes its error downstream instead of stopping the run. The execution finishes. It finishes as a success.</li>

<li><strong>Error output branches.</strong> A node with a separate error output routes failures to a different path. That path might log and stop. The execution still completes normally.</li>

<li><strong>Never Error on HTTP responses.</strong> The HTTP Request node can be configured to treat any status code as a valid response. A run of 500s from an upstream API becomes a workflow that processed zero useful records, successfully.</li>

<li><strong>Empty result sets.</strong> A query that returns nothing is not an error. A sync that syncs nothing, every night, looks exactly like a sync that has nothing to do.</li>
</ul>



<p class="wp-block-paragraph">There are also user reports on the n8n community forum of started and success counters matching even when a node failed outright, in queue mode. I have not reproduced that and I&#8217;d treat it as unconfirmed rather than a known behavior. But it&#8217;s the right instinct: don&#8217;t assume the success counter is a strict complement of failure. Verify it on your own instance before you build alerting on the assumption.</p>



<p class="wp-block-paragraph">For webhook-triggered workflows, newer n8n versions give you a real answer here. The <code>n8n_webhook_request_duration_seconds</code> histogram carries a <code>status_code</code> label, so you can alert on what the caller actually received rather than on what n8n thought about the run:</p>



<pre class="wp-block-code"><code>sum by (workflow_id) (
  rate(n8n_webhook_request_duration_seconds_count{status_code!~"2.."}[5m])
)</code></pre>



<p class="wp-block-paragraph">Worth flagging: the n8n documentation shows this example with <code>status_code!="2.."</code>, using the equality operator. In PromQL, <code>!=</code> compares against a literal string, so that matcher excludes only a status code whose literal value is the three characters <code>2..</code>, which never occurs. It matches everything, including your 200s. Use <code>!~</code> for the regex form. Test any label matcher you copy from documentation by running it in Prometheus and checking the series count against what you expect.</p>



<h3 class="wp-block-heading">Getting a second source of truth</h3>



<p class="wp-block-paragraph">For everything that isn&#8217;t a webhook, the metrics endpoint alone won&#8217;t close this gap. You need a second signal that reads execution outcomes from n8n&#8217;s own store.</p>



<p class="wp-block-paragraph">The public REST API exposes exactly that. Executions can be filtered by status, and the failed ones are one request away:</p>



<pre class="wp-block-code"><code>curl -s -H "X-N8N-API-KEY: $N8N_API_KEY" 
  "https://n8n.example.com/api/v1/executions?status=error&amp;limit=50"</code></pre>



<p class="wp-block-paragraph">Two ways to get that into Grafana. Write a small exporter that polls the API on a timer and serves its own <code>/metrics</code>, or run a scheduled job that pushes counts to a Prometheus Pushgateway. The exporter is more work and behaves correctly. Pushgateway is faster to build and carries the usual caveat: pushed metrics persist until deleted, so a job that stops running leaves a stale value sitting there looking healthy. That reintroduces failure family one through the back door.</p>



<p class="wp-block-paragraph">Two caveats on the API. There&#8217;s an open report that the <code>status=crashed</code> list filter returns executions whose stored status is something else entirely when re-fetched by ID, which would make crash alerting built on the list endpoint unreliable. Check the behavior on your version before depending on it. And n8n&#8217;s log streaming feature, which forwards execution events to syslog or a webhook in real time, is an Enterprise capability. If you&#8217;re on the community edition, the API poll is your path.</p>



<p class="wp-block-paragraph">The third option, and often the best one for a small setup, is an Error Trigger workflow. n8n lets you designate a workflow that runs whenever another workflow fails. Point it at Pushgateway, at Loki, or at a Grafana webhook contact point. It&#8217;s native, it needs no API key, and it gives you the error message rather than just a count. The trade-off is that it only fires on real execution failures, so it inherits every blind spot in this section.</p>



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



<h2 class="wp-block-heading">The Grafana panels that earn their space</h2>



<p class="wp-block-paragraph">Four panels cover most of what you need. Everything past that is usually decoration.</p>



<ol class="wp-block-list">
<li><strong>Executions per workflow, by name.</strong> A time series of <code>rate(n8n_workflow_started_total[5m])</code> joined to <code>n8n_workflow_info</code>. Its real job is showing you the lines that flatten to zero.</li>

<li><strong>Success ratio.</strong> <code>rate(n8n_workflow_success_total[15m]) / rate(n8n_workflow_started_total[15m])</code>. Anything below 1 is worth a look. Anything above 1 means your window is catching a restart and the counters reset.</li>

<li><strong>Queue depth and failed jobs.</strong> <code>n8n_scaling_mode_queue_jobs_waiting</code> next to <code>increase(n8n_scaling_mode_queue_jobs_failed[1h])</code>. A waiting count that climbs and never drains means workers are dead or starved, which looks identical to &#8220;no failures&#8221; on every other panel.</li>

<li><strong>Webhook non-2xx rate.</strong> The query from the previous section. This is the only panel that reports what the outside world experienced.</li>
</ol>



<p class="wp-block-paragraph">Skip the node-level breakdown unless you have a specific reason. It is the most expensive thing you can enable in cardinality terms and it answers a question you can answer faster by opening the execution in the n8n UI.</p>



<h3 class="wp-block-heading">Alert rules worth paging on</h3>



<ol class="wp-block-list">
<li><strong>Named workflow silent.</strong> <code>absent_over_time</code> on each business-critical workflow, window set to roughly twice its expected interval. This is the rule that catches the invoice sync.</li>

<li><strong>Success ratio below threshold.</strong> Sustained for 15 minutes, not instantaneous. A single failed run is not a page.</li>

<li><strong>Queue waiting count rising.</strong> Compare the current value to fifteen minutes ago rather than to a fixed number, so the threshold survives traffic growth.</li>

<li><strong>Webhook error rate.</strong> Per workflow, so a single noisy endpoint doesn&#8217;t mask the rest.</li>

<li><strong>Scrape target down.</strong> The <code>up</code> metric. Obvious, and routinely forgotten, and it invalidates every other rule on this list.</li>
</ol>



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



<h2 class="wp-block-heading">Troubleshooting: the endpoint returns nothing useful</h2>



<ul class="wp-block-list">
<li><strong>&#8220;Cannot GET /metrics&#8221;.</strong> <code>N8N_METRICS</code> isn&#8217;t set on the process you&#8217;re hitting, or it&#8217;s set as a string that n8n doesn&#8217;t read as true. In Docker Compose, quote it as <code>"true"</code> and confirm with <code>docker compose exec n8n env | grep N8N_METRICS</code>.</li>

<li><strong>Endpoint responds but has no n8n_ counters.</strong> You have the default Node.js process metrics and nothing else. Set <code>N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS=true</code> and restart.</li>

<li><strong>Counters exist but no workflow_id label.</strong> <code>N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL</code> is off. Restart is required; it&#8217;s read at startup.</li>

<li><strong>Queue metrics are zero or missing.</strong> They&#8217;re gathered from Bull and exposed on the main instance, not the workers. Querying the worker endpoint gives you nothing. Confirm you&#8217;re looking at the right target.</li>

<li><strong>Counters look wrong after a deploy.</strong> They reset to zero when the process restarts. Always wrap them in <code>rate()</code> or <code>increase()</code>, which handle counter resets. Raw counter values on a dashboard will lie to you every time you deploy.</li>

<li><strong>Prometheus can&#8217;t reach the target.</strong> Usually a Docker network mismatch. Scrape by container name on the shared network rather than by published host port, and check the target page in Prometheus for the actual error rather than guessing.</li>
</ul>



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



<ul class="wp-block-list">
<li>Treating the success counter as proof the automation worked. It proves the run finished.</li>

<li>Building only error-rate alerts, so the workflows that stop entirely go unmonitored.</li>

<li>Enabling every <code>N8N_METRICS_INCLUDE_*</code> variable on day one and discovering the cardinality bill a month later.</li>

<li>Scraping only the main instance in queue mode and losing all worker-side execution data.</li>

<li>Copying label matchers out of documentation without running them and checking the returned series count.</li>

<li>Exposing <code>/metrics</code> publicly because it&#8217;s &#8220;just numbers&#8221;.</li>
</ul>



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



<ul class="wp-block-list">
<li>Decide which workflows are business-critical and write a silence alert for each one by name. Six good heartbeats beat two hundred generic panels.</li>

<li>Run a second source of truth. Metrics tell you about volume, the executions API tells you about outcomes. You need both.</li>

<li>Push the ID-to-name join into a recording rule so panels stay readable and cheap.</li>

<li>Break a staging workflow on purpose and confirm the alert fires. An untested alert is a decoration.</li>

<li>If you&#8217;d rather not run Prometheus and Grafana yourself, Grafana Cloud will scrape a remote endpoint and gives you alerting without a control plane to maintain. Weigh that against your metrics leaving your network.</li>
</ul>



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



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



<h3 class="wp-block-heading">Does n8n expose a failed-executions counter?</h3>



<p class="wp-block-paragraph">Not a reliable per-workflow one. You get started and success counters from the event bus, and a failed-jobs counter for queue mode. Deriving failures by subtracting success from started works only if you&#8217;ve verified on your own instance that a failing run genuinely doesn&#8217;t increment success, which is exactly the assumption that breaks when nodes are set to continue on error.</p>



<h3 class="wp-block-heading">Can I monitor n8n Cloud this way?</h3>



<p class="wp-block-paragraph">No. The <code>/metrics</code> endpoint isn&#8217;t available on n8n Cloud. On Cloud you&#8217;re limited to the public API and the built-in insights views, so the executions-API approach in this post is the only one that transfers.</p>



<h3 class="wp-block-heading">Why does my workflow error rate look like zero when I know things are failing?</h3>



<p class="wp-block-paragraph">Almost always one of three things: the event bus metrics aren&#8217;t enabled so you&#8217;re only seeing Node.js process metrics, the failures are being absorbed by continue-on-error settings and recorded as successes, or you&#8217;re scraping the main instance while the work happens on workers.</p>



<h3 class="wp-block-heading">How do I alert when a scheduled workflow stops running entirely?</h3>



<p class="wp-block-paragraph">Use <code>absent_over_time</code> against that workflow&#8217;s started counter with an exact <code>workflow_id</code> matcher, and set the window to about twice the expected interval. A threshold comparison won&#8217;t work, because a workflow that stops running stops producing the series you&#8217;d compare against.</p>



<h3 class="wp-block-heading">Is enabling the workflow_id label safe on a large instance?</h3>



<p class="wp-block-paragraph">It multiplies your n8n series count by the number of workflows. On a few dozen it&#8217;s negligible. Past a few hundred, combined with node-type labels, watch your Prometheus memory and head series count before and after. Enable the workflow label, leave the node label off, and only turn the node one on when you have a specific question it answers.</p>



<h3 class="wp-block-heading">Should I use OpenTelemetry instead of Prometheus for n8n?</h3>



<p class="wp-block-paragraph">They solve different problems. Metrics answer &#8220;how many, how often, how fast&#8221; cheaply and forever. Traces answer &#8220;where did this run spend its time and what did it call&#8221;, which is better for debugging a slow multi-service workflow. Tracing n8n usually means a custom image with the OpenTelemetry SDK loaded before startup, so it&#8217;s a project rather than an environment variable. Start with metrics; add tracing when you have a latency question metrics can&#8217;t answer.</p>



<h3 class="wp-block-heading">Where do the n8n counters go when the container restarts?</h3>



<p class="wp-block-paragraph">They reset to zero. They&#8217;re in-process counters with no persistence. Prometheus handles this correctly as long as your queries use <code>rate()</code> or <code>increase()</code>. Any panel showing a raw counter value will appear to lose all its history on every deploy.</p>



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



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



<p class="wp-block-paragraph">Monitoring n8n workflow failures in Grafana works, but only once you stop asking the metrics endpoint a question it can&#8217;t answer. It reports activity. Activity and correctness are different things, and the space between them is where the expensive outages live.</p>



<p class="wp-block-paragraph">So build for absence first. A silence alert on the handful of workflows a human would miss will catch more real incidents than any error-rate panel, because errors are loud and stopped schedules are not. Then add attribution with the workflow label, then close the false-success gap with the executions API or an Error Trigger workflow. In that order, because each one is cheap and the value drops off fast after the third.</p>



<h2 class="wp-block-heading">Need help wiring this up?</h2>



<p class="wp-block-paragraph">I build and maintain observability stacks for self-hosted automation platforms, and n8n is a common one. If any of the above sounds like a week you&#8217;d rather not spend, here&#8217;s what I can take off your plate:</p>



<ul class="wp-block-list">
<li>Enabling and securing the n8n metrics endpoint on a private network path, including queue-mode setups with multiple workers</li>

<li>Building Prometheus scrape configs, recording rules and the ID-to-name join so your Grafana panels are readable rather than a wall of hashes</li>

<li>Writing silence and error-rate alert rules that survive counter resets, Grafana No Data states and traffic growth</li>

<li>Adding a second source of truth from the n8n executions API, either as a small exporter or an Error Trigger workflow feeding Loki or Pushgateway</li>

<li>Auditing an existing setup for cardinality problems, stale Pushgateway series and alerts that quietly stopped being able to fire</li>

<li>Running a failure drill against staging so you know which alerts actually work before you need them</li>
</ul>



<p class="wp-block-paragraph">Send me your Compose file, your <code>prometheus.yml</code>, or a screenshot of the panel that looks fine while things are on fire, and I&#8217;ll tell you what&#8217;s missing.</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/monitoring-n8n-workflow-failures-grafana/">Monitoring n8n Workflow Failures in Grafana: Why Your Green Dashboard Is Lying</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Troubleshooting High I/O Wait Without Blaming the Disk</title>
		<link>https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 22 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[bpftrace]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk I/O]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[iostat]]></category>
		<category><![CDATA[Kernel Tuning]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pressure Stall Information]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=248</guid>

					<description><![CDATA[<p>High I/O wait is a symptom, not a diagnosis. It rises when nothing is wrong and stays flat when something is badly wrong, and it only sees one layer of the storage stack. This is how to read iowait and PSI correctly, then work through the five failure families that actually produce storage stalls: device saturation, memory pressure, synchronous writes, throttling, and latency that lives above the block layer entirely.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket says the app is slow. You SSH in, run <code>top</code>, and there it is: <code>wa</code> sitting at forty percent while user CPU barely moves. Obvious conclusion, the disk is dying. Somebody opens a ticket with the host, everyone waits, and the host replies that the volume looks healthy on their side.</p>



<p class="wp-block-paragraph">Sometimes the disk really is dying. More often it isn&#8217;t, and the number you&#8217;re staring at is not measuring what you think it&#8217;s measuring.</p>



<p class="wp-block-paragraph">This post covers troubleshooting high I/O wait the way I&#8217;d actually do it on a production box: what the metric means, why it lies in both directions, and how to work through the five failure families that produce it.</p>



<h2 class="wp-block-heading">What high I/O wait actually measures</h2>



<p class="wp-block-paragraph">Here&#8217;s the definition that matters: iowait is a <em>subcategory of idle time</em>. For a given CPU, it&#8217;s the time that CPU spent doing nothing while at least one task scheduled on it had an outstanding block I/O request.</p>



<p class="wp-block-paragraph">Two consequences fall out of that, and both will burn you. <strong>High iowait can mean nothing is wrong.</strong> If the box has spare CPU and one process is pulling a large file off disk, the CPU genuinely has nothing else to do. Copying a backup will push iowait toward 100 percent on an idle host, and that&#8217;s a working system, not a fault.</p>



<p class="wp-block-paragraph"><strong>Low iowait can hide a severe problem.</strong> This is the one that costs people days. If the CPU has other runnable work, the kernel context switches to it and that time gets counted as user or system, not iowait. A busy application server can be stalling badly on storage while <code>wa</code> reads two percent, because the CPU is never actually idle.</p>



<p class="wp-block-paragraph">Add the averaging problem on top. The <code>wa</code> figure in <code>top</code> is averaged across every core. One thread pinned to one core and blocked hard on disk shows up as roughly three percent on a 32-core machine. Press <code>1</code> in <code>top</code> to break out per-CPU lines before you conclude anything from the aggregate.</p>



<p class="wp-block-paragraph">So iowait is a hint, not a verdict. Treat it as the thing that starts the investigation, never the thing that ends it.</p>



<h2 class="wp-block-heading">Start with pressure, not percentages</h2>



<p class="wp-block-paragraph">Pressure Stall Information is the metric I reach for first, and it exists precisely because iowait is a poor proxy for pain. It answers the question you actually care about: how much time did tasks lose because they couldn&#8217;t get I/O?</p>



<pre class="wp-block-code"><code>cat /proc/pressure/io</code></pre>



<p class="wp-block-paragraph">You get two lines. <code>some</code> is the share of time at least one task was stalled waiting on I/O. <code>full</code> is the share of time <em>every</em> non-idle task was stalled at once, which is CPU capacity being burned for nothing. The <code>avg10</code>, <code>avg60</code> and <code>avg300</code> fields are percentages over the last 10, 60 and 300 seconds. <code>total</code> is a cumulative microsecond counter since boot, which is what you want for graphing because averages smooth away short spikes.</p>



<p class="wp-block-paragraph">The practical rule I use: a rising <code>full</code> line on <code>io</code> is a real user-visible problem almost every time. A high <code>some</code> line with a flat <code>full</code> line usually means one noisy job is waiting while everything else gets on with its life.</p>



<p class="wp-block-paragraph">If <code>/proc/pressure</code> doesn&#8217;t exist, the kernel was built without <code>CONFIG_PSI</code>, or it was built with PSI compiled in but disabled by default, in which case you need <code>psi=1</code> on the kernel command line. On cgroup v2 systems you also get per-cgroup pressure, which is how you narrow from &#8220;the host is stalling&#8221; to &#8220;this container is stalling&#8221; in one command:</p>



<pre class="wp-block-code"><code># Pressure for one systemd slice, useful on any modern distro
cat /sys/fs/cgroup/system.slice/io.pressure

# Pressure for every leaf cgroup that has it, sorted by nothing,
# just eyeball the full lines
find /sys/fs/cgroup -name io.pressure -exec sh -c 
  'printf "%s: " "$1"; sed -n 2p "$1"' _ {} ;</code></pre>



<p class="wp-block-paragraph">If you run Prometheus, node_exporter has exposed these since it gained a pressure collector. The counters are <code>node_pressure_io_waiting_seconds_total</code> for <code>some</code> and <code>node_pressure_io_stalled_seconds_total</code> for <code>full</code>. Rate those and you have a far better storage alert than any threshold on <code>%iowait</code> will ever be. Grafana Cloud and Netdata both chart PSI out of the box if you&#8217;d rather not build the panels yourself.</p>



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



<h2 class="wp-block-heading">Failure family one: the device really is saturated</h2>



<p class="wp-block-paragraph">This is the case everyone assumes, so get it confirmed or eliminated quickly.</p>



<pre class="wp-block-code"><code>iostat -xy 1</code></pre>



<p class="wp-block-paragraph">The <code>-x</code> gives extended per-device stats. The <code>-y</code> matters more than people realise: without it, the first report is a since-boot average, and reading that first block is the single most common iostat mistake there is. It will tell you the disk has been fine all year, which is true and useless.</p>



<p class="wp-block-paragraph">Now the columns worth your attention:</p>



<ul class="wp-block-list">
<li><strong>r_await and w_await</strong>: average milliseconds a read or write took, queue time included. This is the closest thing to &#8220;how slow does storage feel&#8221;. Split reads from writes, because they fail very differently.</li>
<li><strong>aqu-sz</strong>: average queue depth. Requests either queued or in service. A depth that climbs while throughput stays flat is the signature of a genuinely saturated device.</li>
<li><strong>r/s, w/s, rkB/s, wkB/s</strong>: the actual workload. Compare against what the device or volume class is rated for.</li>
<li><strong>%util</strong>: treat with suspicion, see below.</li>
</ul>



<p class="wp-block-paragraph"><strong>The %util trap.</strong> That column counts the fraction of time at least one request was in flight. On a spinning disk that serviced one request at a time, it was a fair saturation signal. On NVMe and on cloud volumes that handle many requests in parallel, a device can sit at 100 percent while running at a fraction of its real capacity. Provisioning a bigger volume off the back of a 99 percent <code>%util</code> reading is a common and expensive way to change nothing. Use <code>aqu-sz</code> and the await columns instead. Similarly, <code>svctm</code> was deprecated and dropped from newer sysstat output because it was unreliable on parallel devices; ignore it if an old build still prints it.</p>



<p class="wp-block-paragraph">Once you know the device is busy, find the culprit. <code>pidstat -d 1</code> gives per-process read and write rates from the same sysstat package and needs nothing special enabled. <code>iotop -o</code> is friendlier, but on kernels from 5.14 onward per-task delay accounting is off by default, so the <code>SWAPIN</code> and <code>IO&gt;</code> columns come back empty until you turn it on:</p>



<pre class="wp-block-code"><code>sudo sysctl kernel.task_delayacct=1
sudo iotop -oPa
# turn it back off when you're done, it costs a little scheduler overhead
sudo sysctl kernel.task_delayacct=0</code></pre>



<p class="wp-block-paragraph">And check the boring things before the clever ones. <code>dmesg -T | grep -iE 'i/o error|reset|medium'</code> catches a failing drive or a flapping controller. <code>smartctl -a /dev/sda</code> catches reallocated sectors on hardware you own. If either of those turns something up, stop reading and replace the disk.</p>



<h2 class="wp-block-heading">Failure family two: memory pressure in an I/O costume</h2>



<p class="wp-block-paragraph">This is the failure family I&#8217;d check second, and it&#8217;s invisible if you only look at storage.</p>



<p class="wp-block-paragraph">When RAM gets tight, the kernel starts evicting page cache and swapping. Both generate disk traffic. The disk gets busy, iowait climbs, and every graph points at storage while the actual constraint is memory. Give the box more RAM and the &#8220;disk problem&#8221; disappears.</p>



<pre class="wp-block-code"><code># si and so are swap-in and swap-out in KB/s.
# Sustained non-zero values are your answer.
vmstat 1

# Are we thrashing writeback? Dirty pages waiting to be written.
grep -E 'Dirty|Writeback' /proc/meminfo

# The direct question
cat /proc/pressure/memory</code></pre>



<p class="wp-block-paragraph">If memory pressure and I/O pressure rise together, fix memory first. Everything else is downstream.</p>



<p class="wp-block-paragraph">There&#8217;s a related trap on the write side. The kernel buffers writes in page cache and flushes them in the background, governed by <code>vm.dirty_background_ratio</code> and <code>vm.dirty_ratio</code>. Cross the background threshold and flusher threads start writing. Cross the hard <code>vm.dirty_ratio</code> threshold and writing processes are forced to do writeback themselves, synchronously. That transition is brutal, and on a box with plenty of RAM and a modest disk the defaults let you accumulate gigabytes of dirty pages and then pay for all of them at once. Lowering the ratios makes writeback more frequent and less spiky, trading away a little write batching. Measure before you commit to it.</p>



<h2 class="wp-block-heading">Failure family three: small synchronous writes</h2>



<p class="wp-block-paragraph">Throughput can be trivial and the system still crawls. A workload doing thousands of tiny <code>fsync</code> calls a second is bound by round-trip latency, not bandwidth, and no amount of extra megabytes per second will help.</p>



<p class="wp-block-paragraph">Databases are the usual source, because durability has a cost and that cost is a flush. MySQL and MariaDB expose it through <code>innodb_flush_log_at_trx_commit</code>; PostgreSQL through <code>synchronous_commit</code>. Loosening either buys real throughput and trades away durability on power loss or crash. That can be a perfectly reasonable trade for a cache or an analytics replica, and a terrible one for anything holding orders or payments. Decide it explicitly, write down what you decided, and don&#8217;t let it be a thing somebody changed once at 2am.</p>



<p class="wp-block-paragraph">The other common source is logging. Application logs, access logs, container JSON logs and a database sharing one volume will starve each other. Splitting hot write paths onto separate devices is unglamorous and works.</p>



<h2 class="wp-block-heading">Failure family four: throttled, not saturated</h2>



<p class="wp-block-paragraph">A throttled device and a saturated device look almost identical from inside the guest: high await, growing queue, miserable latency. The difference is that no amount of tuning inside the box will fix a throttle. Three flavours are worth knowing.</p>



<ul class="wp-block-list">
<li><strong>Cloud volume limits.</strong> Managed block storage gives you a baseline of IOPS and throughput, sometimes with a burst allowance on top. Burn the burst and you drop to baseline, and everything that was fine in testing falls over in production. The tell is a latency cliff that arrives at a consistent point into a workload rather than tracking load smoothly. On AWS, watch the volume-level CloudWatch metrics such as <code>BurstBalance</code> on burstable volume types and <code>VolumeQueueLength</code>; other providers publish equivalents. The fix is a volume class change, not a sysctl.</li>
<li><strong>cgroup limits you set yourself.</strong> On cgroup v2, <code>io.max</code> caps a cgroup at a given bytes-per-second or IOPS figure, and <code>io.weight</code> shares bandwidth proportionally under contention. A limit that made sense when it was written can become the bottleneck a year later, and nothing in the container&#8217;s own metrics announces it.</li>
<li><strong>Neighbours on shared hardware.</strong> On oversubscribed VPS plans your storage is shared, and someone else&#8217;s backup job is your latency spike. This is where cheap and expensive hosting genuinely differ. Budget providers like Contabo and InterServer are fine value for a lot of workloads, but if storage latency is load-bearing for you, look for plans with dedicated NVMe rather than shared pools, and test before you migrate anything that matters.</li>
</ul>



<pre class="wp-block-code"><code># Per-device I/O accounting for a cgroup, including any throttling
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.stat

# Is a cap set? An empty file means no limit on that cgroup.
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.max</code></pre>



<p class="wp-block-paragraph">One more signal while you&#8217;re here. In <code>top</code>, the <code>st</code> column is steal time, meaning the hypervisor gave your vCPU&#8217;s slice to someone else. Steal and iowait rising together on a shared host is a strong hint that the problem is the neighbourhood, not your configuration.</p>



<h2 class="wp-block-heading">Failure family five: the latency isn&#8217;t in the block layer</h2>



<p class="wp-block-paragraph">Here&#8217;s the failure mode that wastes the most time, because every storage metric you check will look healthy.</p>



<p class="wp-block-paragraph"><code>iostat</code> measures the block layer. It cannot see anything above it. So if your latency is in NFS, in a network filesystem, in an overlay or FUSE mount, in filesystem lock contention, or in a journal that&#8217;s serialising writes, the device will report sub-millisecond service times while your application waits tens of milliseconds per operation.</p>



<p class="wp-block-paragraph">The diagnosis is the gap. Measure latency at the application&#8217;s layer and at the block layer, and if the two numbers disagree wildly, the problem lives in between.</p>



<pre class="wp-block-code"><code># Block layer latency as a histogram. Look for a bimodal shape
# or a long tail rather than the average.
sudo biolatency-bpfcc -m 10 1      # Debian/Ubuntu naming
sudo /usr/share/bcc/tools/biolatency -m 10 1   # RHEL family

# Filesystem operations slower than 10ms, with the process and file
sudo ext4slower-bpfcc 10
sudo xfsslower-bpfcc 10

# Everything at the VFS layer, higher overhead, use briefly
sudo fileslower-bpfcc 10</code></pre>



<p class="wp-block-paragraph">If <code>ext4slower</code> is full of hundred-millisecond operations and <code>biolatency</code> shows everything completing in microseconds, you have your answer and it is not the disk. For NFS specifically, <code>nfsiostat</code> splits out RTT from execution time, which separates network cost from server cost. Install the tooling with <code>bpfcc-tools</code> on Debian and Ubuntu, or <code>bcc-tools</code> on RHEL, AlmaLinux and Rocky.</p>



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



<h2 class="wp-block-heading">A working order for troubleshooting high I/O wait</h2>



<p class="wp-block-paragraph">On an unfamiliar box, this is the sequence I work through. Each step either finds the problem or eliminates a family.</p>



<ol class="wp-block-list">
<li>Read <code>/proc/pressure/io</code>. If <code>full</code> is near zero, the stall is not hurting the whole system and you should widen the search before you tune storage.</li>
<li>Check <code>/proc/pressure/memory</code> at the same time. If it&#8217;s rising too, fix memory first.</li>
<li>Run <code>iostat -xy 1</code> for thirty seconds. Note <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> per device. Ignore <code>%util</code>.</li>
<li>List processes stuck in uninterruptible sleep: <code>ps -eo state,pid,comm | awk '$1 ~ /^D/'</code>. That&#8217;s your blocked set, and it names the workload.</li>
<li>Attribute the traffic with <code>pidstat -d 1</code>. Confirm the process you suspect is the process actually doing the I/O.</li>
<li>If await is high, decide saturated versus throttled. Check cgroup <code>io.max</code>, check provider volume metrics, check steal time.</li>
<li>If await is low but the application is slow, jump to the layer gap. Run <code>biolatency</code> and <code>ext4slower</code> together and compare.</li>
<li>Only now consider tuning: dirty ratios, I/O scheduler, flush settings, splitting volumes.</li>
</ol>



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



<ul class="wp-block-list">
<li><strong>Reading iostat&#8217;s first report.</strong> It&#8217;s a since-boot average. Use <code>-y</code>, or discard the first block.</li>
<li><strong>Trusting %util on NVMe or cloud volumes.</strong> Parallel devices break the assumption it was built on.</li>
<li><strong>Alerting on a fixed %iowait threshold.</strong> It fires on healthy backups and stays silent during real stalls on busy hosts.</li>
<li><strong>Changing the I/O scheduler first.</strong> Going from <code>mq-deadline</code> to <code>none</code> on NVMe is sometimes right, but it&#8217;s a tuning step, not a diagnosis, and it fixes nothing if you&#8217;re throttled.</li>
<li><strong>Adding swap to fix swapping.</strong> More swap on a memory-starved box means more disk traffic, not less.</li>
<li><strong>Stopping at the block layer.</strong> If every storage metric looks clean and the app is still slow, you&#8217;re measuring at the wrong layer, not measuring a healthy system.</li>
<li><strong>Testing with the wrong benchmark.</strong> A sequential <code>dd</code> tells you nothing about a workload doing random 4K synchronous writes. Use <code>fio</code> and match the real access pattern.</li>
</ul>



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



<ul class="wp-block-list">
<li><strong>Record PSI continuously.</strong> Storage incidents are short and bursty. Without history you&#8217;re guessing about something that already ended.</li>
<li><strong>Capture a baseline while things are healthy.</strong> Save an <code>iostat -xy</code> sample and a PSI reading from a normal day. &#8220;Is 8ms bad?&#8221; is unanswerable without one.</li>
<li><strong>Keep sar running.</strong> The sysstat package can archive metrics on a schedule, which means <code>sar -d</code> can show you what happened at 3am. Enable it before you need it.</li>
<li><strong>Separate hot write paths.</strong> Database data, database logs and application logs on separate volumes stops one from starving the others.</li>
<li><strong>Cap container logs.</strong> An uncapped JSON log driver quietly filling the root volume is a recurring cause of host-wide stalls.</li>
<li><strong>Alert on latency and pressure, not utilisation.</strong> Users feel <code>w_await</code> and PSI <code>full</code>. Nobody has ever felt a percentage of device busy time.</li>
</ul>



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



<h3 class="wp-block-heading">What is a normal or acceptable I/O wait percentage?</h3>



<p class="wp-block-paragraph">There isn&#8217;t one, and any article that gives you a single number is guessing. Iowait scales with how idle your CPUs are, so the same workload produces very different figures on a busy server and a quiet one. Judge against your own baseline, and prefer PSI, which measures lost time directly rather than as a share of idle.</p>



<h3 class="wp-block-heading">Can high I/O wait be caused by something other than the disk?</h3>



<p class="wp-block-paragraph">Regularly. Memory pressure driving swap and page cache eviction is the most common non-disk cause. Network filesystems, hypervisor contention on shared hosts, and cgroup or cloud volume throttling all produce the same symptom. The block device being genuinely slow is one possibility among several.</p>



<h3 class="wp-block-heading">Why does iostat show 100% utilisation when the disk isn&#8217;t busy?</h3>



<p class="wp-block-paragraph">Because <code>%util</code> measures the fraction of time at least one request was in flight, which made sense for devices that handled one request at a time. NVMe drives and cloud volumes process many requests in parallel, so they can be continuously occupied while nowhere near capacity. Read <code>aqu-sz</code> and the await columns instead.</p>



<h3 class="wp-block-heading">How do I find which process is causing I/O wait?</h3>



<p class="wp-block-paragraph">Start with <code>pidstat -d 1</code>, which needs no special kernel settings. List processes in uninterruptible sleep with <code>ps -eo state,pid,comm</code> and filter for state D to see who is actually blocked. <code>iotop -o</code> gives a live view but needs <code>kernel.task_delayacct=1</code> on recent kernels for its percentage columns to populate.</p>



<h3 class="wp-block-heading">Does adding more RAM reduce I/O wait?</h3>



<p class="wp-block-paragraph">Often, yes, and it&#8217;s underrated. More RAM means a larger page cache, fewer reads reaching the device, and less swapping. If <code>vmstat</code> shows sustained swap activity or memory pressure tracks I/O pressure, RAM is likely the cheapest fix available.</p>



<h3 class="wp-block-heading">Should I change the I/O scheduler to fix high I/O wait?</h3>



<p class="wp-block-paragraph">Only after you know the device is the bottleneck. Check the current setting with <code>cat /sys/block/sda/queue/scheduler</code>. On NVMe, <code>none</code> is a common and sensible default because the hardware already does the queuing. On spinning disks, <code>mq-deadline</code> or <code>bfq</code> give better latency behaviour. It&#8217;s a tuning knob, not a diagnostic, and it will not help if you&#8217;re being throttled.</p>



<h3 class="wp-block-heading">Why is my application slow when iostat says storage is fine?</h3>



<p class="wp-block-paragraph">Because <code>iostat</code> only sees the block layer. Latency in NFS, FUSE, overlay filesystems, journal serialisation or filesystem locking is entirely invisible to it. Run <code>ext4slower</code> or <code>xfsslower</code> alongside <code>biolatency</code>: if the filesystem is slow and the device is fast, the gap between them is your problem.</p>



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



<p class="wp-block-paragraph">High I/O wait is a symptom that points in a general direction, not a diagnosis. It&#8217;s a slice of idle time, averaged across cores, measured at one specific layer of the stack. It goes up when nothing is wrong and stays flat when something is very wrong.</p>



<p class="wp-block-paragraph">Read PSI to find out whether anyone is actually suffering. Read <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> to find out whether the device is the constraint. Compare filesystem latency against block latency to find out whether you&#8217;re even looking at the right layer. Do those three things and the answer usually falls out in a few minutes, instead of after a week of arguing with your hosting provider about a volume that was fine all along.</p>



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



<h2 class="wp-block-heading">Need someone to look at your storage stalls?</h2>



<p class="wp-block-paragraph">I work with teams on exactly this kind of problem, usually the ones that have already survived a round of &#8220;the host says the disk is fine&#8221;. Things I can help with:</p>



<ul class="wp-block-list">
<li>Diagnosing a live or recurring I/O stall and identifying which failure family it belongs to</li>
<li>Setting up PSI, sar and per-cgroup I/O metrics in Prometheus and Grafana, with alerts that fire on latency rather than utilisation</li>
<li>Separating cloud volume throttling from genuine device saturation, and sizing storage correctly instead of guessing upward</li>
<li>Tuning writeback, dirty ratios and I/O schedulers for a specific workload, with before-and-after measurements</li>
<li>Tracing container and Kubernetes I/O contention through cgroup v2 limits, overlay filesystems and log drivers</li>
<li>Benchmarking with <code>fio</code> against your real access pattern so capacity decisions rest on something other than a <code>dd</code> run</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got an <code>iostat</code> capture, a PSI reading or just a graph that doesn&#8217;t make sense, send it over and I&#8217;ll tell you what I see in it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</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>Kubernetes Zero-Downtime Rollouts: Why Your Readiness Probe Isn&#8217;t Saving You</title>
		<link>https://john-nessime.com/blog/devops/kubernetes-zero-downtime-rollouts/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 11 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Graceful Shutdown]]></category>
		<category><![CDATA[Health Checks]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Load Balancing]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Reliability Engineering]]></category>
		<category><![CDATA[Rolling Updates]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=189</guid>

					<description><![CDATA[<p>Readiness probes are necessary for Kubernetes zero-downtime rollouts, but they only close one of four gaps. Here is what actually drops requests during a deploy: the race between SIGTERM and endpoint propagation, probes that lie, surge settings that quietly delete capacity, and load balancers that never watched EndpointSlices in the first place.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/kubernetes-zero-downtime-rollouts/">Kubernetes Zero-Downtime Rollouts: Why Your Readiness Probe Isn&#8217;t Saving You</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 rollout goes green. <code>kubectl rollout status</code> exits zero, every pod reports <code>1/1 Running</code>, and the deploy channel gets the thumbs-up emoji. Then someone from support pastes a screenshot: a spike of 502s, about forty seconds wide, lined up exactly with the deploy. Nobody paged. Nothing restarted. The graph just has a small cliff in it.</p>



<p class="wp-block-paragraph">That gap is the one that costs you, because it is invisible from the control plane. Kubernetes did exactly what you asked. The problem is that a readiness probe only answers one of the four questions that <strong>Kubernetes zero-downtime rollouts</strong> depend on, and it is not the question that usually bites.</p>



<p class="wp-block-paragraph">This post walks through the four failure families in the order they usually show up: traffic arriving too early, traffic arriving too late, capacity disappearing faster than it is replaced, and the load balancer that was never watching your endpoints at all. Each one has a different fix, and stacking all four is what actually gets you a clean deploy.</p>



<h2 class="wp-block-heading">What a zero-downtime rollout actually requires</h2>



<p class="wp-block-paragraph">Three independent things have to be true at the same time, and each is owned by a different part of the system.</p>



<ul class="wp-block-list">
<li><strong>Capacity never dips below what traffic needs.</strong> Owned by the Deployment&#8217;s rolling update strategy.</li>

<li><strong>No pod receives traffic before it can serve it.</strong> Owned by the readiness probe.</li>

<li><strong>No pod receives traffic after it stops serving it.</strong> Owned by graceful termination, and this is the one nobody configures.</li>
</ul>



<p class="wp-block-paragraph">Miss any one and you get errors. Miss the third and you get errors that look like an application bug, because the pod that failed the request no longer exists by the time you go looking for it.</p>



<h2 class="wp-block-heading">Failure family one: traffic arrives before the pod can serve it</h2>



<p class="wp-block-paragraph">This is the well-documented one. With no readiness probe, a pod is considered Ready as soon as its containers are running, which for most runtimes means &#8220;the process was forked&#8221;, not &#8220;the process can answer HTTP&#8221;. The pod lands in the Service&#8217;s EndpointSlice, kube-proxy programs it, and requests start arriving while the app is still loading config, warming a connection pool, or compiling templates.</p>



<h3 class="wp-block-heading">The readiness probe that lies</h3>



<p class="wp-block-paragraph">Adding a probe is easy. Adding one that means something is the part people skip. Two patterns cause trouble:</p>



<p class="wp-block-paragraph"><strong>A readiness endpoint that returns 200 from a static handler.</strong> If <code>/healthz</code> is wired up before the rest of the app is, the probe passes while the service is still useless. The readiness endpoint should be the last thing your app enables, after migrations have run, caches are primed and the pool has connected.</p>



<p class="wp-block-paragraph"><strong>A readiness endpoint that checks downstream dependencies.</strong> This one feels correct and is actively dangerous. If your readiness check pings the database, then a five-second database blip marks <em>every replica</em> unready simultaneously. The Service loses all its endpoints, traffic has nowhere to go, and a brief degradation becomes a full outage. Readiness answers &#8220;can this pod serve traffic&#8221;, not &#8220;is the whole system healthy&#8221;. Check the dependency in your app&#8217;s request path and return a sensible error, or expose it on a separate diagnostic endpoint that nothing routes on.</p>



<h3 class="wp-block-heading">Let the startup probe own the boot budget</h3>



<p class="wp-block-paragraph">For anything slow to boot, do not stretch <code>initialDelaySeconds</code> on the readiness probe until it covers the worst case. That delay applies on every restart forever, and it makes recovery slower than it needs to be. Use a startup probe instead: while it is running, liveness and readiness probes are suppressed entirely, and once it succeeds the fast probes take over.</p>



<pre class="wp-block-code"><code># The startup probe owns the boot window. failureThreshold x periodSeconds
# is the total budget: 30 x 5 = 150 seconds before the kubelet gives up.
startupProbe:
  httpGet:
    path: /healthz/started
    port: http
  periodSeconds: 5
  failureThreshold: 30

# Readiness is fast and cheap. Two consecutive failures pull the pod out
# of rotation within about 6 seconds.
readinessProbe:
  httpGet:
    path: /healthz/ready
    port: http
  periodSeconds: 3
  timeoutSeconds: 2
  failureThreshold: 2

# Liveness is deliberately slower and points at a DIFFERENT endpoint.
# This one restarts the container, so it should only fire on real deadlock.
livenessProbe:
  httpGet:
    path: /healthz/live
    port: http
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3</code></pre>



<p class="wp-block-paragraph">Pointing liveness and readiness at the same URL is a common and expensive mistake. Under load, a slow response fails both: readiness pulls the pod out of rotation, which is right, and liveness kills the container, which is wrong. You lose the pod entirely instead of letting it recover, and the remaining replicas absorb its traffic, get slower, and fail their own liveness checks. That is how a latency spike turns into a rolling restart of the whole Deployment.</p>



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



<h2 class="wp-block-heading">Failure family two: traffic arrives after the pod has stopped</h2>



<p class="wp-block-paragraph">This is the invisible one, and in my experience it accounts for most of the leftover errors after someone has &#8220;already added readiness probes&#8221;.</p>



<p class="wp-block-paragraph">When a pod is deleted, two things happen <em>in parallel</em>, not in sequence:</p>



<ol class="wp-block-list">
<li>The kubelet begins the shutdown sequence: run the preStop hook if one exists, then send SIGTERM to the container&#8217;s main process.</li>

<li>The endpoints controller marks the pod as terminating in its EndpointSlice, and every consumer of that data has to notice and reprogram: kube-proxy on every node, your ingress controller, your service mesh sidecars, and any cloud load balancer with its own target registry.</li>
</ol>



<p class="wp-block-paragraph">Nothing coordinates those two tracks. Kubernetes offers no guarantee that routing has converged before your process gets SIGTERM. On a small cluster the gap might be under a second. On a busy cluster with a few hundred nodes and an ingress controller reconciling on its own schedule, it can be several seconds. Every request that lands in that window hits a socket that is closing or already closed, and your users see a 502 or a connection reset.</p>



<h3 class="wp-block-heading">The preStop sleep, and what it does not do</h3>



<p class="wp-block-paragraph">The fix is to delay SIGTERM so the routing fabric gets a head start. A preStop hook must finish before the TERM signal is sent, so sleeping in it does exactly that.</p>



<pre class="wp-block-code"><code>spec:
  # Total budget for preStop + graceful shutdown. Default is 30.
  terminationGracePeriodSeconds: 60
  containers:
    - name: api
      lifecycle:
        preStop:
          # Native handler, run by the kubelet. No shell needed in the image,
          # which matters for distroless and scratch-based builds.
          sleep:
            seconds: 15</code></pre>



<p class="wp-block-paragraph">If you are on an older cluster without the native <code>sleep</code> handler, the equivalent is an exec hook, which does require a shell and a <code>sleep</code> binary in the image:</p>



<pre class="wp-block-code"><code>lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 15"]</code></pre>



<p class="wp-block-paragraph">Be clear about what this buys you. The sleep does not drain anything. Your application keeps serving normally during it, unaware anything is happening. All it does is push SIGTERM later, so that by the time your process starts shutting down, nothing is sending it new work. Draining in-flight requests is still your application&#8217;s job, on receipt of SIGTERM: stop accepting new connections, finish what is open, then exit.</p>



<p class="wp-block-paragraph">There is no universally correct sleep value, and anyone who gives you one is guessing. Measure it in your own cluster, on a normal weekday, and add margin.</p>



<h3 class="wp-block-heading">The grace period is a shared budget</h3>



<p class="wp-block-paragraph">This trips people up. The <code>terminationGracePeriodSeconds</code> countdown starts when the pod is marked Terminating, which is <em>before</em> the preStop hook runs, not after. The hook and your application&#8217;s shutdown both spend from the same clock. If the grace period is 30, the hook sleeps 25, and your app needs 10 seconds to drain, the app gets SIGKILLed mid-drain and you have made things worse.</p>



<p class="wp-block-paragraph">Size it as: preStop sleep + your longest realistic request + a few seconds of slack. And if your app holds long-lived connections such as WebSockets, gRPC streams or server-sent events, remember that removing an endpoint does nothing to connections that are already established. Either the grace period has to be long enough to see them out, or the client needs to reconnect on its own.</p>



<p class="wp-block-paragraph">One more detail worth knowing before you go looking for it: probes accept their own <code>terminationGracePeriodSeconds</code> override, but only liveness and startup probes. You cannot set it on a readiness probe, because a failing readiness probe never kills anything.</p>



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



<h2 class="wp-block-heading">Failure family three: the rollout deletes capacity faster than it adds it</h2>



<p class="wp-block-paragraph">The default rolling update strategy allows 25% of your pods to be unavailable during the update. On a Deployment with four replicas, that is one pod gone before its replacement is ready. If you are running near capacity, losing 25% of your fleet mid-deploy means queueing, timeouts and retries, none of which show up as a failed rollout.</p>



<pre class="wp-block-code"><code>apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 4
  # Rollout has 10 minutes to finish before it is marked failed.
  progressDeadlineSeconds: 600
  # A new pod must stay Ready this long before it counts. Catches pods
  # that pass the probe and then immediately crash.
  minReadySeconds: 15
  revisionHistoryLimit: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      # Never drop below the declared replica count.
      maxUnavailable: 0
      # Allow one extra pod on top while rolling.
      maxSurge: 1</code></pre>



<p class="wp-block-paragraph">Setting <code>maxUnavailable: 0</code> is the right default for anything user-facing. The trade-off is real and worth stating: you now need headroom for one extra pod, and the rollout is slower because it is strictly sequential, waiting for each new pod to become Ready before removing an old one. On a Deployment with thirty replicas and a slow start-up, that can turn a two-minute deploy into fifteen. Raise <code>maxSurge</code> to a percentage if you have the node capacity and want the speed back.</p>



<p class="wp-block-paragraph"><code>minReadySeconds</code> is the underrated one. Without it, a pod that passes its readiness probe and then falls over two seconds later still counts as a successful step, and the rollout marches on, replacing healthy pods with broken ones until the whole Deployment is bad. A short hold turns that into a stalled rollout you can catch, which is exactly what you want.</p>



<p class="wp-block-paragraph">Rollouts are not the only thing that replaces pods, either. Node drains during a cluster upgrade evict pods too, and they respect PodDisruptionBudgets rather than your rolling update strategy. If you tuned one and not the other, an upgrade on a self-managed cluster, whether on cloud instances or your own VPS nodes from a provider like InterServer or Hetzner, will happily take out more replicas at once than any deploy ever did.</p>



<pre class="wp-block-code"><code>apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
spec:
  # Keep at least three replicas up during voluntary disruptions.
  minAvailable: 3
  selector:
    matchLabels:
      app: api</code></pre>



<h2 class="wp-block-heading">Failure family four: the load balancer was never watching EndpointSlices</h2>



<p class="wp-block-paragraph">Everything above assumes traffic reaches your pods through a Service, where endpoint removal is the whole story. Plenty of production setups do not work that way.</p>



<p class="wp-block-paragraph">Cloud load balancers that target pod IPs directly keep their own target registry. When a pod terminates, a controller has to call the cloud API to deregister it, that call has to be accepted, and the load balancer has to propagate the change to its own data plane. Then a configurable deregistration delay runs before the target is fully removed. That chain is slower and more variable than kube-proxy, often by an order of magnitude, and it explains why teams on managed clusters sometimes report that a preStop sleep of five seconds fixed nothing.</p>



<p class="wp-block-paragraph">Two things help here:</p>



<ul class="wp-block-list">
<li><strong>Pod readiness gates.</strong> These let an external controller add a condition to the pod that must be true before the pod counts as Ready. With a load balancer controller that supports them, a new pod is not considered Ready until the load balancer has actually registered it and passed its own health check. That closes the mirror image of the termination race, on the way in.</li>

<li><strong>Match your preStop sleep to the deregistration delay.</strong> If your target group is configured to drain for 30 seconds, a 5-second sleep is not enough. The sleep needs to cover the time until the load balancer genuinely stops sending new connections.</li>
</ul>



<p class="wp-block-paragraph">The same reasoning applies to an ingress controller running in the cluster. Ingress-NGINX, Traefik and friends watch EndpointSlices and reload their own config, which is fast but not instant, and each has its own graceful shutdown settings that need to be consistent with the pod&#8217;s grace period. If your edge sits behind Cloudflare or another CDN, be aware that it may retry an idempotent request against another origin, which quietly hides some of these errors from your users while leaving them in your logs. That is a good safety net, not a substitute for fixing the race.</p>



<h2 class="wp-block-heading">Verifying a zero-downtime rollout instead of hoping</h2>



<p class="wp-block-paragraph">A rollout that has never been tested under load is a rollout you have not tested. The whole point of these failure modes is that they only appear when requests are in flight.</p>



<ol class="wp-block-list">
<li><strong>Generate steady traffic against the real ingress path.</strong> Not against a pod IP, and not through a port-forward. Those bypass exactly the layers you are trying to test.</li>

<li><strong>Trigger a rollout with no image change</strong>, so you are testing the mechanism rather than your new code.</li>

<li><strong>Watch EndpointSlices in a second terminal</strong> and note how long a terminating pod stays in the list.</li>

<li><strong>Count non-200 responses.</strong> Anything above zero is a bug, not noise.</li>

<li><strong>Repeat under realistic concurrency.</strong> A single-threaded curl loop will miss a 300ms window that a real traffic level would hit hundreds of times.</li>
</ol>



<pre class="wp-block-code"><code># Terminal 1: steady probe traffic, printing status and latency
while true; do
  curl -s -o /dev/null -w '%{http_code} %{time_total}n' https://api.example.com/
  sleep 0.2
done

# Terminal 2: watch endpoints appear and disappear in real time
kubectl get endpointslices -l kubernetes.io/service-name=api -w

# Terminal 3: roll the deployment without changing the image,
# then follow progress and stop on a stall
kubectl rollout restart deployment/api
kubectl rollout status deployment/api --timeout=10m</code></pre>



<p class="wp-block-paragraph">Do this once per service and record the numbers. If you already run Prometheus and Grafana, or a hosted stack like Grafana Cloud or Datadog, put a deploy annotation on your error-rate panel so the correlation is obvious next time instead of something someone has to notice by eye.</p>



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



<p class="wp-block-paragraph"><strong>Rollout hangs, new pods never become Ready.</strong> The readiness probe is failing. <code>kubectl describe pod</code> shows the probe failure and the response it got. Check that the port name in the probe matches a declared <code>containerPort</code> name, that the path exists, and that the app is listening on all interfaces rather than only <code>127.0.0.1</code>, which is a classic one when moving from a local docker-compose setup.</p>



<p class="wp-block-paragraph"><strong>Probes fail only under load.</strong> Look at CPU limits before you look at anything else. A container being CPU-throttled cannot answer a probe within <code>timeoutSeconds</code>, and the default timeout is one second. This produces restart storms that look like an application bug and are actually a resource limit.</p>



<p class="wp-block-paragraph"><strong>Errors persist after adding a preStop sleep.</strong> Either the sleep is shorter than your routing convergence time, or the load balancer is not driven by EndpointSlices at all. Time it: mark the moment of deletion, then watch how long the endpoint stays listed. Increase the sleep past that number and re-test.</p>



<p class="wp-block-paragraph"><strong>Pods stuck Terminating for the full grace period.</strong> Your app is ignoring SIGTERM. This is extremely common when the container&#8217;s entrypoint is a shell script, because the shell runs as PID 1 and does not forward signals to the child. Use the exec form of <code>ENTRYPOINT</code>, or an init like <code>tini</code>, so your process actually receives the signal.</p>



<p class="wp-block-paragraph"><strong>Rollout succeeded but the new version is broken.</strong> Roll back first, investigate second.</p>



<pre class="wp-block-code"><code># Freeze a rollout mid-flight without reverting what has already landed
kubectl rollout pause deployment/api
kubectl rollout resume deployment/api

# Inspect revisions, then go back
kubectl rollout history deployment/api
kubectl rollout undo deployment/api --to-revision=3</code></pre>



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



<ul class="wp-block-list">
<li>Pointing liveness and readiness at the same endpoint, so a slow response restarts the container instead of just removing it from rotation.</li>

<li>Checking databases or downstream APIs in the readiness probe, turning a dependency blip into a total outage.</li>

<li>Leaving <code>maxUnavailable</code> at the default on a service running near capacity.</li>

<li>Adding a preStop sleep longer than the grace period, so the app gets SIGKILLed before it can drain.</li>

<li>Running a single replica and expecting a rolling update to be seamless. With one pod there is nothing to roll onto.</li>

<li>Using RollingUpdate for workloads that cannot tolerate two versions running at once, such as a single-writer process or a schema change that is not backward compatible. Recreate exists for these, and it does mean downtime.</li>

<li>Testing the deploy with no traffic flowing, which makes every one of these failure modes invisible.</li>
</ul>



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



<ul class="wp-block-list">
<li>Three separate endpoints: <code>/healthz/started</code>, <code>/healthz/ready</code>, <code>/healthz/live</code>. They answer different questions and should be allowed to disagree.</li>

<li><code>maxUnavailable: 0</code> plus a <code>maxSurge</code> you have node capacity for, as the default for anything user-facing.</li>

<li>A preStop sleep on every pod behind a Service, sized from a measurement rather than a blog post.</li>

<li>A grace period that covers preStop plus your slowest realistic request, with slack.</li>

<li>Handle SIGTERM properly in the application, and make sure it reaches PID 1.</li>

<li>A PodDisruptionBudget on anything that matters, so node drains are as safe as deploys.</li>

<li><code>minReadySeconds</code> long enough to catch a pod that passes its probe and then dies.</li>

<li>Annotate deploys on your dashboards, and treat any non-200 during a rollout as a defect rather than background noise.</li>
</ul>



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



<h3 class="wp-block-heading">Do readiness probes alone give me zero-downtime deployments?</h3>



<p class="wp-block-paragraph">No. A readiness probe stops traffic reaching a pod that is not ready yet. It does nothing about the window between a pod being marked for deletion and the routing layer noticing, and nothing about capacity dipping during the rollout. You need the rolling update strategy and graceful termination as well.</p>



<h3 class="wp-block-heading">How long should the preStop sleep be?</h3>



<p class="wp-block-paragraph">Long enough for everything that routes traffic to your pods to stop doing so. Measure it: delete a pod, watch the EndpointSlice, and time how long it stays listed. Behind a cloud load balancer, add its deregistration delay on top. Then confirm by running the load test with the value you picked.</p>



<h3 class="wp-block-heading">Should the readiness probe check the database?</h3>



<p class="wp-block-paragraph">Almost never. It couples the availability of every replica to a single shared dependency, so one slow query can empty your Service of endpoints all at once. Handle dependency failures in the request path and return a meaningful error instead.</p>



<h3 class="wp-block-heading">What is the difference between maxSurge and maxUnavailable?</h3>



<p class="wp-block-paragraph"><code>maxSurge</code> is how many pods you may run <em>above</em> the replica count during a rollout. <code>maxUnavailable</code> is how many you may drop <em>below</em> it. Surge costs resources; unavailability costs capacity. For zero downtime you want unavailability at zero and surge at whatever your nodes can absorb.</p>



<h3 class="wp-block-heading">Why do I still get 502s on EKS, GKE or another managed cluster?</h3>



<p class="wp-block-paragraph">Cloud load balancers that target pod IPs maintain their own target registry, and deregistration goes through a cloud API rather than kube-proxy. That path is slower and more variable. Use pod readiness gates if your load balancer controller supports them, and size the preStop sleep against the target group&#8217;s deregistration delay rather than against kube-proxy.</p>



<h3 class="wp-block-heading">Can I get zero-downtime rollouts with a StatefulSet?</h3>



<p class="wp-block-paragraph">The same probe and termination mechanics apply, but StatefulSets update pods one at a time in reverse ordinal order and cannot surge, so there is no extra pod covering the gap. Whether that is zero downtime depends entirely on whether your application tolerates losing one member at a time, which for most quorum-based systems it does and for a single-writer database it does not.</p>



<h3 class="wp-block-heading">Does kubectl rollout status prove the deploy was clean?</h3>



<p class="wp-block-paragraph">It proves the desired number of pods reached Ready within the progress deadline. It says nothing about requests that failed on the way there. The only thing that proves a clean deploy is traffic flowing through the real ingress path with a zero error count.</p>



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



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



<p class="wp-block-paragraph">Kubernetes zero-downtime rollouts are not a feature you turn on. They are the result of four separate things being configured correctly at once, and the readiness probe is only the most obvious of them. The one that actually drops requests in most clusters is the race at the other end: your process receives SIGTERM while the routing layer still believes the pod is a valid destination.</p>



<p class="wp-block-paragraph">Delay the signal, size the grace period to cover the delay plus a real request, and then prove it with traffic flowing. Everything else is bookkeeping.</p>



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



<p class="wp-block-paragraph">Most of this work is unglamorous: reading manifests, timing endpoint propagation, and finding the one Deployment that never got a preStop hook. Things I can help with:</p>



<ul class="wp-block-list">
<li>Auditing your Deployments and Helm charts for probe, surge and termination settings, with a prioritised list of what to change</li>

<li>Measuring real endpoint propagation time in your cluster and sizing preStop and grace periods from that number</li>

<li>Building a load-test harness that runs against a rollout in CI, so a regression fails the pipeline instead of the pager</li>

<li>Tracking down 502s and connection resets that only appear during deploys, including cloud load balancer and ingress controller paths</li>

<li>Splitting a single overloaded health endpoint into proper startup, readiness and liveness checks</li>

<li>Adding PodDisruptionBudgets and drain-safety so cluster upgrades stop being an event</li>
</ul>



<p class="wp-block-paragraph">If you have a Deployment manifest, a <code>kubectl describe pod</code> output, or a graph showing your error rate during a deploy, send it over 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/devops/kubernetes-zero-downtime-rollouts/">Kubernetes Zero-Downtime Rollouts: Why Your Readiness Probe Isn&#8217;t Saving You</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</title>
		<link>https://john-nessime.com/blog/devops/docker-log-rotation/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 10 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Container Runtime]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk Space]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[journald]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Loki]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[rsyslog]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Syslog]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=182</guid>

					<description><![CDATA[<p>Docker's default logging driver writes container output to a JSON file with no size limit and no rotation. This is a practical guide to Docker log rotation: what the defaults actually do, why your daemon.json change did nothing, why deleting the log file did not free any disk, and how to choose between json-file, local, journald and shipping logs off the box.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-log-rotation/">Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Monitoring says the API is healthy. The ticket says nobody can deploy. You SSH in, run <code>df -h</code>, and the root filesystem is at 100%. A few minutes of poking around <code>/var/lib/docker</code> later you find it: one container, one file, a double-digit number of gigabytes of newline-delimited JSON, written continuously since the day that container was created.</p>



<p class="wp-block-paragraph">Nothing was broken. That is the part people get stuck on. The container did exactly what it was told, the daemon did exactly what it was told, and the disk filled anyway. Docker&#8217;s default logging driver has no size cap and no rotation, and it has been that way for a long time on purpose.</p>



<p class="wp-block-paragraph">This post covers Docker log rotation properly: what the defaults actually do, why the <code>daemon.json</code> change you already made did nothing, why deleting the log file did not give you your disk back, how the available drivers differ in the ways that matter under load, and what to do when you need space in the next sixty seconds.</p>



<h2 class="wp-block-heading">The default is unbounded, and that is documented behaviour</h2>



<p class="wp-block-paragraph">Docker&#8217;s default logging driver is <code>json-file</code>. It captures stdout and stderr from the container&#8217;s main process and appends one JSON object per line to a file on the host, one file per container. Each line carries the message, whether it came from stdout or stderr, and a timestamp.</p>



<p class="wp-block-paragraph">The important part is the option defaults. For <code>json-file</code>, <code>max-size</code> defaults to unlimited, <code>max-file</code> defaults to 1, and <code>compress</code> defaults to false. Put together, that means: one file, no rotation, grows until something else breaks.</p>



<p class="wp-block-paragraph">Docker&#8217;s own documentation is upfront about why. Keeping <code>json-file</code> unrotated by default preserves backwards compatibility with older engines and suits situations where Docker is acting as a runtime under Kubernetes, where the kubelet handles rotation itself. It is a deliberate choice that happens to be the wrong one for almost every standalone host.</p>



<p class="wp-block-paragraph">Before you change anything, find out what you actually have. These three commands answer different questions and you want all three:</p>



<pre class="wp-block-code"><code># What driver is the daemon handing to newly created containers?
docker info --format '{{.LoggingDriver}}'

# What is this specific container actually using right now?
docker inspect --format '{{.HostConfig.LogConfig}}' my-api

# Where does its log live, and how big has it got?
docker inspect --format '{{.LogPath}}' my-api
sudo du -h "$(docker inspect --format '{{.LogPath}}' my-api)"</code></pre>



<p class="wp-block-paragraph">The gap between the first command and the second is where most of the pain lives, and we will come back to it.</p>



<p class="wp-block-paragraph">To find the offenders across the whole host, look at the per-container directories under the Docker data root. Each one holds that container&#8217;s log file plus a small amount of metadata, so the sizes are close enough to be useful:</p>



<pre class="wp-block-code"><code>sudo du -sh /var/lib/docker/containers/* | sort -rh | head -10</code></pre>



<p class="wp-block-paragraph">Container IDs are not memorable, so map the winner back to a name with <code>docker ps --no-trunc</code> or by grepping the ID against <code>docker ps -aq</code>. In my experience the top entry is almost never the application you were worried about. It is usually a reverse proxy logging every request, a health-check loop firing every few seconds, or a debug flag somebody set during an incident and never turned off.</p>



<h2 class="wp-block-heading">Setting up Docker log rotation on the daemon</h2>



<p class="wp-block-paragraph">The daemon-wide fix goes in <code>/etc/docker/daemon.json</code>. If the file does not exist, create it. If it does, merge these keys in rather than overwriting it, because that file often already carries storage driver, address pool or registry mirror settings you do not want to lose.</p>



<pre class="wp-block-code"><code>{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3",
    "compress": "true"
  }
}</code></pre>



<p class="wp-block-paragraph">What each key buys you:</p>



<ul class="wp-block-list">
<li><code>max-size</code> caps a single file. Accepts an integer plus <code>k</code>, <code>m</code> or <code>g</code>. This is the setting that stops the runaway.</li>

<li><code>max-file</code> caps how many files are kept. When rotation would create one too many, the oldest is deleted. It is <em>only effective if <code>max-size</code> is also set</em>, which is the single most common misconfiguration I see.</li>

<li><code>compress</code> gzips rotated files. The active file stays uncompressed so it can still be appended to and read. Log text compresses extremely well, so this is close to free retention.</li>
</ul>



<p class="wp-block-paragraph">Two things trip people up here. First, every value in <code>log-opts</code> must be a JSON string, including the numeric ones. Writing <code>"max-file": 3</code> without quotes is invalid and the daemon will refuse to start cleanly. Second, validate before you restart, because a malformed <code>daemon.json</code> on a production host is a much worse afternoon than a full disk:</p>



<pre class="wp-block-code"><code># Confirm the file is valid JSON before touching the daemon
sudo python3 -c 'import json,sys; json.load(open("/etc/docker/daemon.json"))' 
  &amp;&amp; echo "daemon.json parses OK"

sudo systemctl restart docker</code></pre>



<p class="wp-block-paragraph">Now do the arithmetic, because this is a budget and not a magic switch. Worst case per container is <code>max-size</code> multiplied by <code>max-file</code>. At 10m and 3, that is roughly 30 MB uncompressed per container, less once compression kicks in on the rotated files. Multiply by your container count and compare against the disk. On a small VPS plan from a provider like InterServer or Hetzner, where the whole root volume might be 20 to 40 GB shared with images and volumes, forty containers at 30 MB each is a real slice of your budget rather than a rounding error.</p>



<h2 class="wp-block-heading">Why your daemon.json change did nothing</h2>



<p class="wp-block-paragraph">This is the failure mode that costs the most time, because it looks exactly like the fix not working.</p>



<p class="wp-block-paragraph">Logging configuration is baked into a container&#8217;s host config when the container is <em>created</em>. Restarting the daemon does not rewrite it. Restarting the container does not rewrite it either, because <code>docker restart</code> stops and starts the same container object rather than making a new one. Your new limits apply to containers created after the daemon restart and to nothing else.</p>



<p class="wp-block-paragraph">So the container that filled your disk keeps filling your disk, its log file still has no cap, and <code>docker info</code> cheerfully reports the new defaults. That mismatch is exactly what the second command in the earlier block is for.</p>



<p class="wp-block-paragraph">To actually apply it, recreate the container and then verify rather than assuming:</p>



<pre class="wp-block-code"><code># Compose: recreate the service so it picks up the new daemon defaults
docker compose up -d --force-recreate web

# Verify the container is really carrying the limits
docker inspect --format '{{.HostConfig.LogConfig}}' web</code></pre>



<p class="wp-block-paragraph">You want to see the driver name followed by the map of options. An empty map means the container is still unbounded no matter what the daemon says.</p>



<p class="wp-block-paragraph">Note that recreating a container discards its existing log history along with the old container. If those logs matter for an ongoing investigation, copy them off first.</p>



<h3 class="wp-block-heading">Pinning limits per service in Compose</h3>



<p class="wp-block-paragraph">Daemon defaults are a floor, not a policy. A chatty ingress proxy and a quiet cron worker do not deserve the same budget, and per-service configuration in Compose overrides <code>daemon.json</code> entirely. A YAML anchor keeps it from turning into copy-paste sprawl:</p>



<pre class="wp-block-code"><code>x-logging: &amp;default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"
    compress: "true"

services:
  web:
    image: nginx:alpine
    logging: *default-logging

  worker:
    image: myorg/worker:latest
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"
        compress: "true"</code></pre>



<p class="wp-block-paragraph">Be aware that the <code>logging</code> block replaces the daemon config rather than merging with it. If you specify a driver and forget the options, you get that driver with <em>its</em> defaults, which for <code>json-file</code> means straight back to unbounded. Half-specifying is worse than not specifying at all.</p>



<h2 class="wp-block-heading">You deleted the log file and df did not move</h2>



<p class="wp-block-paragraph">Classic 3am mistake, and it is worth understanding rather than memorising.</p>



<p class="wp-block-paragraph">When you <code>rm</code> the JSON log of a running container, you remove the directory entry. You do not remove the inode, because the Docker daemon still holds an open file descriptor pointing at it. The kernel keeps the data allocated until the last descriptor closes. So the file disappears from <code>ls</code>, the daemon keeps writing into a file you can no longer see, and <code>df</code> reports exactly the same usage as before. Space comes back only when you restart the daemon or recreate the container.</p>



<p class="wp-block-paragraph"><code>truncate -s 0</code> is the right tool. It zeroes the file in place without unlinking it, so the descriptor stays valid, the daemon keeps appending to the same inode, and the blocks are released immediately:</p>



<pre class="wp-block-code"><code>sudo truncate -s 0 "$(docker inspect --format '{{.LogPath}}' my-api)"</code></pre>



<p class="wp-block-paragraph">There is a cost. Truncating out from under the daemon can leave an in-flight <code>docker logs -f</code> stream reading from an offset that no longer exists, so the follow appears to hang with no new output until you reattach. It is a known behaviour rather than corruption, and reattaching clears it. Live with it during an incident, but do not build it into a cron job.</p>



<p class="wp-block-paragraph">The same reasoning applies to <code>logrotate</code> rules pointed at <code>/var/lib/docker/containers/*/*.log</code>. Rotating with <code>copytruncate</code> works mechanically, but you are reaching into files the daemon considers its own, and you inherit the follow-stream issue on every rotation cycle. Docker&#8217;s own docs warn against external tools touching those files. If the driver can rotate for you, let it.</p>



<h2 class="wp-block-heading">Choosing a driver, and what each one costs</h2>



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



<p class="wp-block-paragraph">The default. Widest tooling compatibility, since every log shipper and every scraper knows this format. <code>docker logs</code> works natively. Its genuine downside is the format itself: JSON per line is verbose, so you pay in disk and in parse cost on read. Reach for it when something downstream reads those files directly.</p>



<h3 class="wp-block-heading">local</h3>



<p class="wp-block-paragraph">Docker&#8217;s recommendation for standalone hosts, and the one I reach for first. It uses a more efficient on-disk format, and critically it <em>rotates and compresses by default</em>, retaining five files of 20 MB each per container out of the box. <code>docker logs</code> works exactly as normal. The trade-off is that the format is Docker&#8217;s own, so third-party agents that expect to tail JSON files cannot read it directly. If your entire log path is <code>docker logs</code> plus a shipper that talks to the daemon, this is the safest default you can pick.</p>



<h3 class="wp-block-heading">journald</h3>



<p class="wp-block-paragraph">Hands container output to systemd&#8217;s journal, which means you get <code>journalctl</code> filtering, structured fields, and the journal&#8217;s own size management via <code>SystemMaxUse</code> in <code>journald.conf</code>. This is a good fit on hosts where you already read system logs that way and want one retention policy instead of two.</p>



<p class="wp-block-paragraph">The catch nobody mentions: the journal has rate limiting on by default. A container in a crash loop can trip it, and messages get dropped with a note saying how many were suppressed. That is fine for noise and terrible for the incident you are actually debugging. If you go this route, look at <code>RateLimitIntervalSec</code> and <code>RateLimitBurst</code> before you need them, not after.</p>



<h3 class="wp-block-heading">syslog, fluentd and the remote drivers</h3>



<p class="wp-block-paragraph">These push logs off the box to rsyslog, Fluentd, Loki, or a hosted platform. Disk pressure genuinely goes away because the host stops being the system of record. That is the right destination for anything beyond a handful of hosts.</p>



<p class="wp-block-paragraph">What you buy with it is a dependency. Your logging path now has a network hop, and the failure modes of that hop become your failure modes. Whether it is self-hosted Grafana Loki, or a managed service like Grafana Cloud, Better Stack or Datadog, the operational question is the same: what happens to the container when the collector is unreachable? Answer that during setup rather than during an outage.</p>



<h3 class="wp-block-heading">none</h3>



<p class="wp-block-paragraph">Discards output entirely. Legitimate for a container that already writes its own logs to a mounted volume, and a trap everywhere else, because <code>docker logs</code> returns nothing and you will waste twenty minutes concluding the container is broken.</p>



<h3 class="wp-block-heading">The blocking behaviour worth knowing about</h3>



<p class="wp-block-paragraph">By default the logging path is blocking. If the driver cannot keep up, the container&#8217;s write to stdout blocks, which means your application stalls. On a local file that is rarely an issue. Pointed at a remote collector that has gone slow, it means a logging problem becomes an application latency problem.</p>



<pre class="wp-block-code"><code>docker run -it 
  --log-opt mode=non-blocking 
  --log-opt max-buffer-size=4m 
  alpine ping 127.0.0.1</code></pre>



<p class="wp-block-paragraph">Non-blocking mode buffers in memory and drops messages once the buffer is full rather than stalling the writer. The default buffer is 1 MB. You are choosing which failure you prefer: a slow application, or missing log lines exactly when things are going wrong. There is no answer that is right everywhere, but for a user-facing service I would rather lose lines than add latency.</p>



<h3 class="wp-block-heading">Dual logging, and why docker logs still works</h3>



<p class="wp-block-paragraph">If you switch to a remote driver and find <code>docker logs</code> still returns output, that is dual logging. When the configured driver cannot serve reads, the engine keeps a local cache using the <code>local</code> driver so the command still works. That cache rotates by default, limited to five files of 20 MB each per container before compression.</p>



<p class="wp-block-paragraph">Useful to know for two reasons. It explains why disk usage does not drop to zero after you move logging off the box. And it means the local cache has its own retention that you can tune or disable separately from the driver you configured.</p>



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



<h2 class="wp-block-heading">Reclaiming space when the disk is already full</h2>



<p class="wp-block-paragraph">Order matters here. Free space first, fix the cause second, verify third.</p>



<ol class="wp-block-list">
<li>Confirm logs are actually the problem. Compare <code>du -sh /var/lib/docker/containers</code> against <code>docker system df</code>, which reports images, volumes and build cache. Images are often the real culprit and truncating logs will not help you.</li>

<li>Identify the largest log files with the <code>du</code> and <code>sort</code> command from earlier.</li>

<li>Copy anything you need out first, if the logs are part of an active investigation.</li>

<li>Truncate the offenders with <code>truncate -s 0</code>. Never <code>rm</code>.</li>

<li>Set the limits in <code>daemon.json</code>, validate the JSON, restart the daemon.</li>

<li>Recreate the containers so the limits take effect, then confirm with <code>docker inspect</code> on each one.</li>

<li>Add a disk alert. A full disk should never be discovered by a human noticing something else is broken.</li>
</ol>



<p class="wp-block-paragraph">Step seven is the one that actually stops this recurring. Rotation caps the damage, but a container that suddenly starts logging a hundred times more than usual is telling you something, and a disk-usage alert on the Docker data root is how you hear it.</p>



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



<p class="wp-block-paragraph"><strong>Rotation is configured but files still grow past the cap.</strong> The container predates the change. Check <code>docker inspect --format '{{.HostConfig.LogConfig}}'</code> and recreate it.</p>



<p class="wp-block-paragraph"><strong>The daemon will not start after editing daemon.json.</strong> Almost always invalid JSON, or unquoted values in <code>log-opts</code>. Read the actual error with <code>journalctl -u docker --no-pager -n 50</code> rather than guessing.</p>



<p class="wp-block-paragraph"><strong>max-file is set but only one file ever appears.</strong> <code>max-file</code> does nothing without <code>max-size</code>. Set both.</p>



<p class="wp-block-paragraph"><strong>docker logs -f hangs after showing existing output.</strong> Something truncated the file underneath the daemon, usually an external <code>logrotate</code> rule. Reattach to recover, then remove the rule.</p>



<p class="wp-block-paragraph"><strong>docker logs returns nothing at all.</strong> Check the driver. <code>none</code> discards everything, and some remote drivers only serve reads through the dual-logging cache.</p>



<p class="wp-block-paragraph"><strong>Disk usage did not drop after deleting log files.</strong> The daemon still holds the descriptors. Restart the daemon to release the inodes, and use <code>truncate</code> next time.</p>



<p class="wp-block-paragraph"><strong>Logs vanish sooner than the retention you configured.</strong> On <code>journald</code>, check journal rate limiting and <code>SystemMaxUse</code>. On a remote driver in non-blocking mode, check whether the buffer is overflowing.</p>



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



<ul class="wp-block-list">
<li>Assuming a daemon restart applies the new limits to running containers. It does not.</li>

<li>Setting <code>max-file</code> without <code>max-size</code> and believing rotation is on.</li>

<li>Writing numeric values unquoted in <code>log-opts</code>.</li>

<li>Overwriting an existing <code>daemon.json</code> instead of merging, and silently dropping storage or network settings.</li>

<li>Using <code>rm</code> on a live container&#8217;s log file and concluding the disk report is broken.</li>

<li>Specifying a <code>logging.driver</code> in Compose without its <code>options</code>, which resets that service back to the driver&#8217;s own unbounded defaults.</li>

<li>Pointing host <code>logrotate</code> at the Docker containers directory when the driver could have handled it.</li>

<li>Sizing rotation per container without multiplying by container count against the actual disk.</li>
</ul>



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



<ul class="wp-block-list">
<li>Set logging limits in <code>daemon.json</code> as part of host provisioning, before any container exists. This belongs in your Ansible role or cloud-init, not in a runbook.</li>

<li>Prefer the <code>local</code> driver on standalone hosts unless something specifically needs to read the JSON files.</li>

<li>Turn on <code>compress</code>. Log text compresses well enough that you get more retention for the same disk.</li>

<li>Size per service rather than uniformly. Give the noisy proxy a tight cap and the service you actually debug a generous one.</li>

<li>Do the multiplication. Worst-case host usage is <code>max-size</code> × <code>max-file</code> × container count, and it should be a fraction of your disk, not most of it.</li>

<li>Alert on Docker data root usage at a threshold that leaves you time to act.</li>

<li>Treat on-host logs as a short buffer, not an archive. Anything you need next quarter belongs in a log platform, not on the VPS.</li>

<li>Verify with <code>docker inspect</code> after any change. <code>docker info</code> tells you the daemon&#8217;s intent, not what your containers carry.</li>
</ul>



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



<h3 class="wp-block-heading">Does Docker rotate container logs automatically?</h3>



<p class="wp-block-paragraph">Not with the default driver. <code>json-file</code> ships with <code>max-size</code> unlimited and <code>max-file</code> set to 1, so a single file grows without bound. The <code>local</code> driver does rotate and compress by default. If you have not explicitly configured Docker log rotation, assume it is off.</p>



<h3 class="wp-block-heading">Where are Docker container logs stored on the host?</h3>



<p class="wp-block-paragraph">Under the Docker data root, one directory per container, which on a standard Linux install means <code>/var/lib/docker/containers/</code>. Rather than assembling the path by hand, ask the daemon: <code>docker inspect --format '{{.LogPath}}' &lt;container&gt;</code>. That works regardless of a custom <code>data-root</code>.</p>



<h3 class="wp-block-heading">How do I clear Docker logs without stopping the container?</h3>



<p class="wp-block-paragraph">Truncate the file to zero bytes with <code>truncate -s 0</code> on the path from <code>{{.LogPath}}</code>. Do not use <code>rm</code>: the daemon holds an open descriptor, so deleting the directory entry leaves the data allocated and frees no space until the daemon restarts.</p>



<h3 class="wp-block-heading">Why did my daemon.json rotation settings not apply?</h3>



<p class="wp-block-paragraph">Because logging config is fixed at container creation time. A daemon restart changes what new containers get; it does not rewrite existing ones, and <code>docker restart</code> reuses the same container object. Recreate the container, then confirm with <code>docker inspect --format '{{.HostConfig.LogConfig}}'</code>.</p>



<h3 class="wp-block-heading">Should I use the local driver or json-file?</h3>



<p class="wp-block-paragraph">Use <code>local</code> if nothing outside Docker reads the log files directly, since it rotates and compresses by default and uses less disk for the same content. Use <code>json-file</code> if an agent or shipper on the host tails those files, because it cannot parse the <code>local</code> format. Either way <code>docker logs</code> behaves the same.</p>



<h3 class="wp-block-heading">Can I use logrotate for Docker container logs instead?</h3>



<p class="wp-block-paragraph">You can, and plenty of hosts do, but the driver options are the better tool. A <code>copytruncate</code> rule pointed at the containers directory reaches into files the daemon treats as private, and can leave <code>docker logs -f</code> streams stalled after each rotation. Use <code>logrotate</code> for logs your applications write to mounted volumes, and let the driver handle stdout.</p>



<h3 class="wp-block-heading">How much disk should I budget for container logs?</h3>



<p class="wp-block-paragraph">Worst case is <code>max-size</code> × <code>max-file</code> per container, multiplied by how many containers run on the host. At 10m and 3 files across thirty containers that is roughly 900 MB before compression. Pick numbers that leave the total at a comfortable fraction of the disk, and keep long-term retention off the box.</p>



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



<p class="wp-block-paragraph">Docker log rotation is not a feature you turn on once at the daemon and forget. It is a property each container carries from the moment it is created, and every container that existed before your change is still running under the old rules.</p>



<p class="wp-block-paragraph">So the working sequence is always the same: set the limits, restart the daemon, recreate the containers, then verify with <code>docker inspect</code> rather than trusting <code>docker info</code>. Add a disk alert on the Docker data root so the next surprise arrives as a notification and not as a failed deploy. Everything else in this post is detail around that.</p>



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



<h2 class="wp-block-heading">Need a hand with container logging on your hosts?</h2>



<p class="wp-block-paragraph">Log sprawl is one of those problems that is cheap to fix in an hour and expensive to leave alone. If you would rather have someone go through it with you, this is the kind of work I take on:</p>



<ul class="wp-block-list">
<li>Auditing every container on a host for logging driver and retention, and producing the recreate plan that actually applies the limits</li>

<li>Sizing a rotation budget against your real disk, container count and log volume, per service rather than one flat number</li>

<li>Emergency recovery on a full Docker host, including the cases where deleting files did not give the space back</li>

<li>Choosing between <code>json-file</code>, <code>local</code>, <code>journald</code> and shipping off-box, including the blocking behaviour trade-offs under load</li>

<li>Setting up centralised logging with Loki, rsyslog or a hosted platform, with sane retention on both ends</li>

<li>Baking logging limits into provisioning so new hosts are never born unbounded, plus the disk alerts to catch what rotation cannot</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me your <code>daemon.json</code>, the output of <code>docker ps</code> and a <code>du -sh</code> of your containers directory, and I will tell you what I would change.</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/docker-log-rotation/">Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>journald vs rsyslog: Where Your Linux Logs Actually Live</title>
		<link>https://john-nessime.com/blog/devops/journald-vs-rsyslog-where-logs-live/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 08 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Disk Space]]></category>
		<category><![CDATA[journalctl]]></category>
		<category><![CDATA[journald]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[rsyslog]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Syslog]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=168</guid>

					<description><![CDATA[<p>On a stock Linux server, journald and rsyslog are both running and both storing the same events, with retention policies that don't know about each other. Here's where each log line actually lives, the three failure modes that bite in production, and how to decide which copy you keep.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/journald-vs-rsyslog-where-logs-live/">journald vs rsyslog: Where Your Linux Logs Actually Live</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The disk alert usually comes first. <code>/var</code> sitting at 94 percent on a box that runs three services and logs almost nothing interesting. You go digging and find the same night&#8217;s SSH failures in two places: compressed binary files under <code>/var/log/journal/</code>, and plain text in <code>/var/log/secure</code>. Same events. Two copies. Nobody decided that. It is just what the distro shipped.</p>



<p class="wp-block-paragraph">The worse version of this problem is the mirror image. You reboot a server to clear something, come back to read what led up to it, and the journal starts at the reboot. Everything before is gone. Not rotated, not compressed, not archived somewhere clever. Gone, because the journal was living in RAM and nobody ever told it otherwise.</p>



<p class="wp-block-paragraph">Both of those are the same misunderstanding wearing different clothes: not knowing where your logs actually live. This post walks through the <strong>journald vs rsyslog</strong> split on a modern Linux server, what each one really stores, the three failure modes that bite in production, and how to decide which of the two is the copy you keep.</p>



<h2 class="wp-block-heading">What actually happens when a process logs a line</h2>



<p class="wp-block-paragraph">On any systemd-based distribution, <code>systemd-journald</code> is the first stop for almost everything. It is not one option among several. It sits underneath.</p>



<ul class="wp-block-list">
<li>Anything a unit writes to stdout or stderr, because systemd wires those to the journal by default.</li>

<li>Kernel messages, read from the kernel ring buffer.</li>

<li>Classic <code>syslog(3)</code> calls from libc, which land on the <code>/dev/log</code> socket that journald owns.</li>

<li>Native journal API calls from anything linked against libsystemd, which carry structured key-value fields instead of a flat string.</li>

<li>Audit records, if <code>Audit=yes</code> is in effect.</li>
</ul>



<p class="wp-block-paragraph">journald writes all of that into indexed binary journal files. Then, on most server distributions, rsyslog gets a <em>second copy</em> of the same events and writes them out as text into <code>/var/log/messages</code>, <code>/var/log/secure</code>, <code>/var/log/maillog</code> and friends.</p>



<p class="wp-block-paragraph">That handoff happens one of two ways, and which one your box uses matters more than most people expect. More on that below. First, the part that costs you an outage.</p>



<p class="wp-block-paragraph">Before changing anything, look at the effective journald configuration rather than the file you think is authoritative. Distributions ship drop-ins under <code>/usr/lib/systemd/journald.conf.d/</code> that quietly override upstream defaults:</p>



<pre class="wp-block-code"><code># Print the merged configuration, including every drop-in, in load order
systemd-analyze cat-config systemd/journald.conf</code></pre>



<p class="wp-block-paragraph">This is the single most useful command in this whole post. Half the arguments about &#8220;what the default is&#8221; evaporate once you run it, because upstream systemd and your distribution frequently disagree about <code>ForwardToSyslog</code>.</p>



<h2 class="wp-block-heading">Failure one: the journal that was never on disk</h2>



<p class="wp-block-paragraph">journald&#8217;s <code>Storage=</code> setting defaults to <code>auto</code>. That word does more work than it looks like. Under <code>auto</code>, journald writes to <code>/var/log/journal/</code> <em>only if that directory already exists</em>. If it does not, journald falls back to <code>/run/log/journal/</code>, which is tmpfs. Memory. Wiped on reboot.</p>



<p class="wp-block-paragraph">Nothing warns you. <code>journalctl</code> works fine, colours are pretty, filters work. You just silently have no history.</p>



<p class="wp-block-paragraph">The quickest test is boot history. If the journal is persistent you see multiple boots; if it is volatile you see exactly one:</p>



<pre class="wp-block-code"><code>journalctl --list-boots</code></pre>



<p class="wp-block-paragraph">To make it persistent, create the directory, let systemd-tmpfiles apply the correct ownership and ACLs, then restart the daemon and flush anything still held in RAM:</p>



<pre class="wp-block-code"><code>sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
sudo journalctl --flush
journalctl --list-boots</code></pre>



<p class="wp-block-paragraph">The <code>systemd-tmpfiles</code> step is the one people skip. The journal directory needs specific group ownership and ACLs so that unprivileged users can read their own entries and members of the <code>systemd-journal</code> group can read everything. Creating the directory with a bare <code>mkdir</code> and walking away usually works, but it is the kind of &#8220;usually&#8221; that produces a confusing permissions ticket six months later.</p>



<p class="wp-block-paragraph">If you want the behaviour to be explicit rather than inferred from a directory&#8217;s existence, set it in <code>/etc/systemd/journald.conf.d/00-storage.conf</code>:</p>



<pre class="wp-block-code"><code>[Journal]
Storage=persistent</code></pre>



<p class="wp-block-paragraph">A drop-in file is better than editing the main config, because package upgrades will not fight you over it.</p>



<h2 class="wp-block-heading">Failure two: paying for the same log line twice</h2>



<p class="wp-block-paragraph">Once the journal is persistent, you are storing everything twice on a default server build: once as compressed binary in the journal, once as text under <code>/var/log/</code>. Two stores, two completely separate retention policies, neither of which knows the other exists.</p>



<p class="wp-block-paragraph">The journal is capped by <code>SystemMaxUse=</code> and <code>SystemKeepFree=</code>. Left unset, they default to 10 percent and 15 percent of the filesystem respectively, and journald honours whichever is stricter. Recent systemd also caps those calculated defaults so they do not grow without bound on a large disk, which is worth knowing if you have ever wondered why a huge volume did not produce a proportionally huge journal.</p>



<p class="wp-block-paragraph">The text copy is capped by <code>logrotate</code>, configured somewhere completely different, usually <code>/etc/logrotate.d/rsyslog</code>, on a weekly or daily schedule with its own <code>rotate</code> count.</p>



<p class="wp-block-paragraph">Measure both before you tune either:</p>



<pre class="wp-block-code"><code># What the journal is actually consuming on disk
journalctl --disk-usage

# What everything under /var/log costs, sorted, biggest last
sudo du -sh /var/log/* | sort -h</code></pre>



<p class="wp-block-paragraph">On a small VPS this stops being academic quickly. A modest instance from a provider like InterServer, Hetzner or DigitalOcean often has <code>/var</code> sharing a single root volume of 20 to 40 GB. A journal allowed to take 10 percent of that, plus four weeks of rotated text logs, plus a database, plus a container image cache, is how you end up paged at two in the morning for something that is not a real incident.</p>



<p class="wp-block-paragraph">If you decide the text files are the copy you keep, put the journal on a short leash:</p>



<pre class="wp-block-code"><code>[Journal]
# Hard ceiling on total journal size
SystemMaxUse=200M
# Never let the journal push free space below this
SystemKeepFree=1G
# Discard entries older than this regardless of size
MaxRetentionSec=2day</code></pre>



<p class="wp-block-paragraph">Those three do different jobs and you generally want at least two of them. <code>SystemMaxUse</code> bounds the journal itself. <code>SystemKeepFree</code> protects the rest of the filesystem from the journal. <code>MaxRetentionSec</code> bounds it in time, which is what auditors and data-retention policies actually care about.</p>



<p class="wp-block-paragraph">To reclaim space immediately without waiting for the next rotation:</p>



<pre class="wp-block-code"><code># Trim archived journal files until the total falls below 500M
sudo journalctl --vacuum-size=500M

# Or trim by age instead
sudo journalctl --vacuum-time=7d</code></pre>



<p class="wp-block-paragraph">One caveat that surprises people: vacuuming only removes <em>archived</em> journal files. The currently active file is never deleted. If almost all your usage is in one large active file, run <code>journalctl --rotate</code> first, then vacuum.</p>



<h2 class="wp-block-heading">Failure three: two rate limiters, both silent</h2>



<p class="wp-block-paragraph">This is the one that is invisible until it matters, and it is the real reason to understand the journald vs rsyslog relationship rather than treating them as interchangeable.</p>



<p class="wp-block-paragraph">There are <em>two independent rate limiters</em> in the default pipeline, and both drop messages quietly.</p>



<h3 class="wp-block-heading">journald&#8217;s limiter</h3>



<p class="wp-block-paragraph">journald applies <code>RateLimitIntervalSec=</code> and <code>RateLimitBurst=</code> per service, defaulting to 30 seconds and 10000 messages. Exceed the burst inside the interval and the rest of that service&#8217;s messages in that window are discarded. Not queued. Discarded.</p>



<p class="wp-block-paragraph">The one piece of good news is that journald tells you, in the journal itself. Grep for it:</p>



<pre class="wp-block-code"><code>journalctl --grep="Suppressed" -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">A chatty web server or a container using the journald log driver will hit this. Rather than disabling rate limiting globally, override it for the one unit that needs it, using a drop-in:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/nginx.service.d/logging.conf
[Service]
LogRateLimitIntervalSec=30s
LogRateLimitBurst=50000</code></pre>



<p class="wp-block-paragraph">Per-unit values override <code>journald.conf</code> for that unit only, which is exactly what you want. Turning the global limiter off means one runaway process can fill your disk and take the box down. Turning it up for the single service you know is noisy is a bounded decision.</p>



<h3 class="wp-block-heading">rsyslog&#8217;s limiter</h3>



<p class="wp-block-paragraph">If rsyslog is reading via the <code>imjournal</code> module, it applies <em>its own</em> rate limit on top, defaulting to 20000 messages per 600 seconds. A message can therefore survive journald&#8217;s limiter, land in the journal, and still never reach <code>/var/log/messages</code> or your remote log host.</p>



<p class="wp-block-paragraph">That is the nasty case, because your local investigation finds the events but your centralised alerting never fired. Two people look at two sources and reach opposite conclusions about whether something happened.</p>



<p class="wp-block-paragraph">rsyslog exposes counters for this through its statistics module, including how many messages it read from the journal, how many it submitted onward, and how many it discarded to rate limiting. Enable <code>impstat</code> if you run anything log-volume-sensitive; the discard counter is the number that tells you whether your pipeline is lying to you.</p>



<h2 class="wp-block-heading">How rsyslog gets its copy: imjournal or imuxsock</h2>



<p class="wp-block-paragraph">There are two mechanisms, they behave differently under load, and running both at once is a classic source of duplicate lines.</p>



<h3 class="wp-block-heading">imuxsock: journald pushes a copy to a socket</h3>



<p class="wp-block-paragraph">With <code>ForwardToSyslog=yes</code> in journald, journald writes a classic syslog-formatted copy of each message to a dedicated socket, and rsyslog reads it with <code>imuxsock</code>.</p>



<ul class="wp-block-list">
<li><strong>Where it wins:</strong> simple, no state file, no second rate limiter, and no possibility of rsyslog reading back its own output and looping.</li>

<li><strong>Where it does not:</strong> you get the flat syslog view only. Structured journal fields such as the originating unit are not carried across.</li>
</ul>



<h3 class="wp-block-heading">imjournal: rsyslog pulls from the journal</h3>



<p class="wp-block-paragraph"><code>imjournal</code> reads journal files directly and keeps a state file so it can resume where it left off after a restart.</p>



<ul class="wp-block-list">
<li><strong>Where it wins:</strong> structured fields survive the trip. If you want to route or template on the systemd unit name rather than parsing it out of a message string, this is the only option of the two.</li>

<li><strong>Where it does not:</strong> extra rate limiter to reason about, a state file that can drift, and a documented risk that a corrupted journal database causes rsyslog to re-read the same entries in a loop. rsyslog&#8217;s own documentation recommends using <code>imuxsock</code> instead unless you specifically need the structured data.</li>
</ul>



<p class="wp-block-paragraph">A representative <code>imjournal</code> load line, with the rate limit stated explicitly rather than left implicit:</p>



<pre class="wp-block-code"><code>module(load="imjournal"
       StateFile="imjournal.state"
       Ratelimit.Interval="600"
       Ratelimit.Burst="20000")</code></pre>



<p class="wp-block-paragraph">Writing the defaults out by hand is worth the two extra lines. The next person to debug missing messages will see the limiter exists instead of assuming there isn&#8217;t one.</p>



<p class="wp-block-paragraph">To find out which mechanism your box is using right now:</p>



<pre class="wp-block-code"><code>grep -R "imjournal|imuxsock" /etc/rsyslog.conf /etc/rsyslog.d/ 2&gt;/dev/null
grep -R "ForwardToSyslog" /etc/systemd/journald.conf /etc/systemd/journald.conf.d/ /usr/lib/systemd/journald.conf.d/ 2&gt;/dev/null</code></pre>



<p class="wp-block-paragraph">If the first command returns both modules and the second returns <code>ForwardToSyslog=yes</code>, you have a duplication problem. Pick one path and disable the other.</p>



<h2 class="wp-block-heading">Only one of the two leaves the box</h2>



<p class="wp-block-paragraph">This is the argument that settles most journald vs rsyslog debates in practice. journald is a local store. In a stock server install it has no mechanism for shipping logs to another host. There are separate systemd components for journal upload and reception, and third-party projects that read the journal and forward it, but none of that is in the default path.</p>



<p class="wp-block-paragraph">rsyslog forwards as a first-class feature, and it does the part that actually matters: it buffers when the destination is unreachable.</p>



<pre class="wp-block-code"><code>global(workDirectory="/var/spool/rsyslog")

action(type="omfwd"
       target="logs.example.net"
       port="514"
       protocol="tcp"
       action.resumeRetryCount="-1"
       queue.type="LinkedList"
       queue.filename="fwd_loghost"
       queue.maxDiskSpace="1g"
       queue.saveOnShutdown="on")</code></pre>



<p class="wp-block-paragraph">Line by line, because these are not decoration:</p>



<ul class="wp-block-list">
<li><code>workDirectory</code> is where spool files get written. Without it, the disk queue has nowhere to go.</li>

<li><code>protocol="tcp"</code> rather than UDP. UDP syslog silently drops under congestion, which defeats the point of forwarding at all.</li>

<li><code>queue.type="LinkedList"</code> makes the action asynchronous so a slow destination does not block local processing.</li>

<li><code>queue.filename</code> is what actually enables disk assistance. In-memory queue first, spilling to disk when it fills. The name must be unique across every action in the config.</li>

<li><code>queue.maxDiskSpace</code> caps that spool. Set it, or a long outage at the far end fills the disk you were trying to protect.</li>

<li><code>queue.saveOnShutdown="on"</code> persists whatever is still queued across a service restart.</li>
</ul>



<p class="wp-block-paragraph">One honest caveat on <code>action.resumeRetryCount="-1"</code>. Infinite retry is the right default for &#8220;never lose a log line&#8221;, but there are reported cases where a relay under sustained back-pressure stops accepting new inbound messages while retrying forever. If your host is a relay rather than a leaf, test that behaviour deliberately with the destination firewalled off before you trust it.</p>



<p class="wp-block-paragraph">rsyslog is not the only way off the box. Grafana Alloy into Loki, Vector, and Fluent Bit all read the journal directly and speak modern backends, and hosted platforms such as Better Stack, Papertrail or Datadog will take a plain syslog stream if you would rather not run the storage side. The mechanism differs; the decision does not. Something has to own the write path off the machine, and journald on its own is not it.</p>



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



<h2 class="wp-block-heading">journald vs rsyslog: how I would decide</h2>



<p class="wp-block-paragraph">Stop asking which is better. Ask which one is the copy you are willing to be judged on, then make the other one cheap.</p>



<ol class="wp-block-list">
<li><strong>Do logs need to leave this machine?</strong> If yes, rsyslog or a dedicated shipper owns the outbound path. That is settled before anything else.</li>

<li><strong>Do you troubleshoot mostly by unit?</strong> If <code>journalctl -u something -b</code> is your muscle memory, journald is your primary read path and should get the retention budget.</li>

<li><strong>Do existing tools read text files?</strong> Fail2ban, logwatch, older SIEM collectors and a lot of home-grown scripts parse <code>/var/log/*</code>. Turning rsyslog off breaks them quietly.</li>

<li><strong>How much disk do you actually have?</strong> Under about 40 GB, keeping two full copies is an unforced error. Cap one hard.</li>

<li><strong>Is tamper-evidence a requirement?</strong> journald supports Forward Secure Sealing, which detects after-the-fact modification of journal files. Shipping to an append-only remote store is the more commonly accepted answer, and the two are not mutually exclusive.</li>
</ol>



<p class="wp-block-paragraph">The configuration I reach for first on a general-purpose server: journald persistent with a hard cap for a few days of fast local querying, rsyslog kept for the syslog files that other tools expect plus reliable forwarding off-box, <code>imuxsock</code> as the handoff unless something genuinely needs structured fields, and logrotate retention trimmed down because the remote copy is the one that matters for anything older than a week.</p>



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



<ul class="wp-block-list">
<li><strong>Journal only shows the current boot.</strong> Volatile storage. <code>journalctl --list-boots</code> confirms it, and the fix is the persistent-storage sequence above.</li>

<li><strong>Events in journalctl but not in /var/log/messages.</strong> Either rsyslog is not reading from the journal at all, or the <code>imjournal</code> rate limiter is discarding. Check which input module is loaded first.</li>

<li><strong>Every line appears twice in the text logs.</strong> Both <code>imjournal</code> and <code>imuxsock</code> are active while <code>ForwardToSyslog=yes</code>. Disable one path.</li>

<li><strong>Bursts of messages disappear during incidents.</strong> That is a rate limiter, and incidents are exactly when services get loud. Search for suppression notices and raise the limit for that specific unit.</li>

<li><strong>journalctl reports corruption or behaves oddly.</strong> Run <code>journalctl --verify</code>. If files are damaged, <code>journalctl --rotate</code> starts a fresh active file so new writes are clean, then vacuum the bad archives.</li>

<li><strong>&#8220;Journal has been rotated since unit was started.&#8221;</strong> Not an error. Rotation happened mid-session, so <code>journalctl -u</code> cannot map the full range. Re-run the query without the unit filter or widen the time range.</li>

<li><strong>Disk still full after vacuuming.</strong> Vacuum skips the active journal file. Rotate first, then vacuum again.</li>
</ul>



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



<ul class="wp-block-list">
<li>Assuming the journal is persistent because <code>journalctl</code> returns results. It always returns results. The question is how far back.</li>

<li>Disabling rsyslog on a box where fail2ban, logwatch or a scraper still reads <code>/var/log/secure</code>. Nothing errors. Detection just stops.</li>

<li>Setting <code>RateLimitBurst=0</code> globally to &#8220;stop losing logs&#8221;, then having a crash-looping service fill the disk in an afternoon.</li>

<li>Editing <code>/etc/systemd/journald.conf</code> directly and being surprised when a vendor drop-in overrides it. Use a drop-in of your own with a name that sorts later.</li>

<li>Forwarding over UDP because it is the one-line version. It drops silently under exactly the load that produced the logs you wanted.</li>

<li>Enabling a disk-assisted queue without <code>queue.maxDiskSpace</code>, turning a remote outage into a local disk-full outage.</li>

<li>Tuning journald limits on a machine whose real problem is that one application logs every health check at info level. Fix the source first.</li>
</ul>



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



<ul class="wp-block-list">
<li>Make storage explicit. <code>Storage=persistent</code> or <code>Storage=volatile</code>, never left to <code>auto</code> on a server you care about.</li>

<li>Always set <code>SystemKeepFree</code> alongside <code>SystemMaxUse</code>. The first bounds the journal, the second protects everything else on the volume from it.</li>

<li>Decide consciously which store is authoritative, and write it in the runbook. Two stores with no stated owner means neither gets maintained.</li>

<li>Use exactly one journal-to-rsyslog path. Both is duplication; neither is silent data loss.</li>

<li>Override rate limits per unit, not globally.</li>

<li>Monitor <code>journalctl --disk-usage</code> as a metric, not as something you check after the alert.</li>

<li>Ship off-box with TCP and a bounded disk-assisted queue, and test it with the destination firewalled off before you rely on it.</li>

<li>Keep the local copy short and the remote copy long. Local disk is the expensive place to store history.</li>
</ul>



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



<h3 class="wp-block-heading">Can I just disable rsyslog and use journald only?</h3>



<p class="wp-block-paragraph">On a self-contained box with no remote logging and nothing parsing text files, yes, and it saves real disk. Before you do it, grep your configuration management and cron jobs for <code>/var/log/</code> paths, and check whether fail2ban or any monitoring agent reads those files. That is where this bites people.</p>



<h3 class="wp-block-heading">Can I disable journald and use rsyslog only?</h3>



<p class="wp-block-paragraph">Not meaningfully. journald is how systemd captures unit stdout and stderr, so it stays in the path regardless. What you can do is set <code>Storage=volatile</code> with a small <code>RuntimeMaxUse</code> so it holds a short in-memory window and rsyslog owns everything durable.</p>



<h3 class="wp-block-heading">Where are journald logs stored?</h3>



<p class="wp-block-paragraph"><code>/var/log/journal/</code> when persistent, <code>/run/log/journal/</code> when volatile. They are indexed binary files, not text, so <code>grep</code> and <code>tail</code> do not work on them directly. Use <code>journalctl</code>, or <code>journalctl -o json</code> if you want to pipe structured output into something else.</p>



<h3 class="wp-block-heading">Why does journalctl show entries that never reached /var/log/messages?</h3>



<p class="wp-block-paragraph">Three usual causes: rsyslog is not reading the journal at all, the <code>imjournal</code> rate limiter dropped them, or a severity filter such as <code>MaxLevelSyslog</code> or an rsyslog rule excluded that facility or priority. Check in that order.</p>



<h3 class="wp-block-heading">Is the binary journal format a problem for log analysis?</h3>



<p class="wp-block-paragraph">Locally, no. Structured fields and fast filtering are genuinely better than parsing text with regex. It becomes a problem when you need to move logs somewhere else, because almost every collector expects text or JSON. That conversion step is precisely the role rsyslog or a modern shipper plays.</p>



<h3 class="wp-block-heading">How much disk should the journal be allowed to use?</h3>



<p class="wp-block-paragraph">Work backwards from how far back you actually query locally. If you rarely look past yesterday, a few hundred megabytes with <code>MaxRetentionSec</code> set to two or three days is plenty. Anything older belongs in a remote store where it is cheaper and survives the machine.</p>



<h3 class="wp-block-heading">Does journald compress logs automatically?</h3>



<p class="wp-block-paragraph">Yes. <code>Compress=</code> defaults to enabled and applies to entries above a size threshold, which is one reason the journal is often smaller than the equivalent text files despite carrying more metadata. It does not remove the need for a size cap.</p>



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



<p class="wp-block-paragraph">The journald vs rsyslog question is not a contest. On a stock server both are running, both are storing the same events, and the defaults were chosen by your distribution rather than by you. The failure modes all come from that: a journal in RAM that vanishes on reboot, two uncoordinated retention policies eating the same disk, and two rate limiters dropping messages without anyone noticing.</p>



<p class="wp-block-paragraph">Run <code>systemd-analyze cat-config systemd/journald.conf</code> and <code>journalctl --list-boots</code> on your servers. Two commands, thirty seconds, and you will know whether the logs you would reach for during an incident are actually there.</p>



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



<h2 class="wp-block-heading">Need help sorting out your logging pipeline?</h2>



<p class="wp-block-paragraph">Logging is one of those areas where the defaults work well enough to hide the problem until an incident makes it expensive. If any of the above sounded familiar, these are the things I take on:</p>



<ul class="wp-block-list">
<li>Auditing journald and rsyslog on existing servers and telling you exactly where each log line lives, how long it survives, and what is being silently dropped.</li>

<li>Fixing volatile journals, runaway <code>/var</code> growth and duplicate text-and-binary storage on small VPS instances.</li>

<li>Setting up reliable off-box forwarding with disk-assisted queues, TCP or RELP transport, and TLS where it is required.</li>

<li>Migrating from text-file scraping to a structured pipeline into Loki, Elasticsearch or a hosted log platform, without losing the alerts you already depend on.</li>

<li>Tuning rate limits and retention per service so noisy applications stop hiding the messages that matter.</li>

<li>Writing the runbook that says which store is authoritative, so the next engineer does not have to reverse-engineer it during an outage.</li>
</ul>



<p class="wp-block-paragraph">Send me your <code>journald.conf</code>, your <code>rsyslog.conf</code>, or the output of <code>journalctl --disk-usage</code> and I will tell you what I see before we talk about scope.</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/journald-vs-rsyslog-where-logs-live/">journald vs rsyslog: Where Your Linux Logs Actually Live</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Podman vs Docker: Is the Switch Actually Worth It?</title>
		<link>https://john-nessime.com/blog/devops/podman-vs-docker-worth-it/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 07 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Container Runtime]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Podman]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Quadlet]]></category>
		<category><![CDATA[Rootless Containers]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Systemd]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=162</guid>

					<description><![CDATA[<p>Podman is the better-designed container engine and Docker is the better-supported one. A working engineer's comparison: the architectural difference that drives everything, where each one wins and loses, the four things that genuinely break in a migration (compose, the Docker socket, privileged ports, restart on boot), and a decision procedure to run against your own setup.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/podman-vs-docker-worth-it/">Podman vs Docker: Is the Switch Actually Worth It?</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 question usually arrives from outside the engineering team. Someone doing an access review notices that half the developers are in the <code>docker</code> group on the build hosts, asks what that grants them, and gets an answer they do not like. Or procurement forwards a note about Docker Desktop licensing and wants to know whether there is a free option.</p>



<p class="wp-block-paragraph">Either way you end up looking at Podman, reading a Podman vs Docker comparison table that says &#8220;daemonless&#8221; and &#8220;rootless by default&#8221;, and trying to work out whether those words are worth a migration.</p>



<p class="wp-block-paragraph">Here is the short version, and I will spend the rest of the post defending it. The architectural difference between the two engines is real and it matters more than the feature tables suggest. The migration cost is also real, and it is almost entirely concentrated in four places that comparison posts tend to skip. Whether the switch is worth it depends on which of those four you actually depend on.</p>



<p class="wp-block-paragraph">This post covers the one architectural difference everything else follows from, an honest profile of each engine with where it wins and where it does not, the four things that genuinely break when you switch, and a decision procedure you can run against your own setup rather than against a generic one.</p>



<h2 class="wp-block-heading">The difference everything else follows from</h2>



<p class="wp-block-paragraph">Docker is a client and a daemon. The <code>docker</code> CLI is a thin thing that talks to <code>dockerd</code>, which runs as root and owns every container on the host. Podman has no daemon. When you run <code>podman run</code>, the container is started as a child of your own process and supervised by a small monitor, not handed off to a privileged background service.</p>



<p class="wp-block-paragraph">Two consequences follow, and they are the whole argument.</p>



<p class="wp-block-paragraph"><strong>Membership of the <code>docker</code> group is equivalent to root.</strong> Not &#8220;close to root&#8221;, not &#8220;root if misconfigured&#8221;. The daemon runs as root and will happily create a container that bind-mounts the host&#8217;s root filesystem, so anyone who can talk to the socket can read or write anything on the box. Docker&#8217;s own documentation says as much. This is the finding that shows up in access reviews, and it is not a bug you can configure away without switching to rootless mode.</p>



<p class="wp-block-paragraph"><strong>Process supervision belongs to whoever started the container.</strong> With Docker, the daemon restarts your containers according to the restart policy, and if the daemon wedges you have a bad afternoon regardless of what the containers are doing. With Podman, containers are ordinary processes, so systemd supervises them the same way it supervises everything else on the machine, with the same logging, dependency ordering and failure handling.</p>



<p class="wp-block-paragraph">Podman&#8217;s rootless mode builds on user namespaces: UID 0 inside the container maps to your unprivileged UID on the host. A break-out lands in a normal user account rather than on a root shell. Docker has a rootless mode too and it works, but it is an opt-in you configure rather than the path of least resistance, which in practice means far fewer installations run it.</p>



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



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



<ul class="wp-block-list">
<li><strong>The ecosystem assumes it.</strong> Testcontainers, VS Code Dev Containers, Traefik&#8217;s container discovery, Portainer, most CI runners, half the SDKs on your machine: all of them expect a Docker socket. Some work fine against Podman&#8217;s compatible socket. Some do not, and finding out which is your problem.</li>

<li><strong>Compose is first-party.</strong> Docker Compose is maintained by the same people who maintain the engine, tracks the Compose specification closely, and gets features like <code>--wait</code> and profiles as soon as they exist. Nothing in the Podman world matches that.</li>

<li><strong>Every answer to every question already exists.</strong> Fifteen years of Stack Overflow. That is not a technical merit but it is a real operational one at 2am.</li>

<li><strong>Cross-platform experience is smoother.</strong> Docker Desktop on macOS and Windows is a polished product, and the VM it manages is largely invisible.</li>
</ul>



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



<ul class="wp-block-list">
<li><strong>The root daemon is a standing liability.</strong> One privileged process that everything flows through, plus a group that is root by another name.</li>

<li><strong>Docker Desktop is licensed, and the terms are specific.</strong> Docker&#8217;s own license page states Desktop is free for personal use, education, non-commercial open source, and small businesses defined as fewer than 250 employees <em>and</em> less than ten million dollars in annual revenue. Cross either threshold and commercial use needs a paid subscription. Government entities need one regardless. Docker Engine and the Moby project are not affected. This catches people out because the software installs without asking for a key.</li>

<li><strong>systemd integration is bolted on.</strong> You can wrap a Compose stack in a unit file, but systemd is supervising a wrapper, not the containers.</li>

<li><strong>Restarting the daemon touches everything.</strong> An engine upgrade is a whole-host event rather than a per-service one.</li>
</ul>



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



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



<ul class="wp-block-list">
<li><strong>Rootless is the default, not a project.</strong> You get the safer posture by doing nothing special, which is the only kind of security control that survives contact with a busy team.</li>

<li><strong>Quadlet is genuinely better than restart policies.</strong> You write a declarative unit file, systemd generates a real service from it, and container lifecycle becomes indistinguishable from any other service on the box. This is the feature I would actually miss if I went back.</li>

<li><strong>No licensing question to answer.</strong> Podman is Apache 2.0 with no commercial-use restriction, and on Red Hat Enterprise Linux, Fedora, Rocky and AlmaLinux it is the packaged, supported option already sitting in the repos.</li>

<li><strong>Pods and Kubernetes YAML are first-class.</strong> <code>podman kube play</code> runs a Kubernetes manifest locally, and <code>podman generate kube</code> goes the other way. If your destination is Kubernetes, the mental model transfers.</li>

<li><strong>Per-user isolation comes free.</strong> Two developers on a shared build host cannot see or stop each other&#8217;s containers, because they are running in separate user namespaces rather than as siblings under one daemon.</li>
</ul>



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



<ul class="wp-block-list">
<li><strong>Compose is a compatibility story, not a native one.</strong> More on this below, because it is the single biggest source of migration pain.</li>

<li><strong>Rootless networking has sharp edges.</strong> Traffic goes through a userspace network stack, and the behaviour is not identical to a root bridge. Source IPs, host-loopback access and privileged ports all behave differently, and you will meet at least one of those.</li>

<li><strong>Swarm and Docker plugins do not exist.</strong> Podman does not implement Swarm mode, and Docker volume and network plugins are not supported. If you use either, the conversation is over.</li>

<li><strong>Desktop platforms mean a VM you can see.</strong> Podman on macOS and Windows runs a Podman machine, and the seams show more than Docker Desktop&#8217;s do.</li>

<li><strong>Documentation assumes Docker.</strong> Every vendor quickstart, every README, every &#8220;run this to try it&#8221; snippet is written for <code>docker run</code>. Most translate directly. The ones that do not cost you an hour each.</li>
</ul>



<h2 class="wp-block-heading">The four things that actually break</h2>



<p class="wp-block-paragraph">This is the part worth reading twice. Command compatibility is not the problem; <code>podman</code> accepts almost every <code>docker</code> flag you know. The problems are elsewhere.</p>



<h3 class="wp-block-heading">1. Compose</h3>



<p class="wp-block-paragraph">There are two routes and they are not equivalent.</p>



<p class="wp-block-paragraph"><strong>podman-compose</strong> is a separate Python implementation. It covers the common cases and diverges on the uncommon ones. Container naming is the one that bites scripts first: it has historically used underscores where Docker Compose v2 uses hyphens, so anything referencing <code>project-web-1</code> by name stops finding it.</p>



<p class="wp-block-paragraph"><strong>The Podman socket plus real Docker Compose</strong> is the route I would take. Podman exposes a Docker-compatible REST API over a systemd socket, and the actual Compose binary talks to it:</p>



<pre class="wp-block-code"><code># Start the Docker-compatible API socket for your user.
systemctl --user enable --now podman.socket

# Confirm it exists before pointing anything at it.
ls -l /run/user/$(id -u)/podman/podman.sock

# Point the real Docker Compose at Podman instead of dockerd.
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
docker compose up -d</code></pre>



<p class="wp-block-paragraph">You keep the tool that tracks the Compose specification and swap only the engine underneath. The catch is that the API is a compatibility layer, not the Docker daemon, so behaviour can differ at the edges. Run your real compose file through it before you commit to anything.</p>



<h3 class="wp-block-heading">2. Anything that mounts the Docker socket</h3>



<p class="wp-block-paragraph">Traefik discovering containers by label, Portainer, Testcontainers in your test suite, any CI job that does Docker-in-Docker. All of these want <code>/var/run/docker.sock</code>. The Podman socket serves the same API and many of them work against it unchanged, but &#8220;many&#8221; is doing work in that sentence. Inventory what mounts the socket <em>before</em> you plan the migration, not after.</p>



<h3 class="wp-block-heading">3. Ports below 1024</h3>



<p class="wp-block-paragraph">A rootless container cannot bind a privileged port, because the process genuinely is not privileged. If your compose file publishes 80 and 443, it will fail, and the error will not obviously say why.</p>



<p class="wp-block-paragraph">Three ways out, in the order I would try them:</p>



<ol class="wp-block-list">
<li><strong>Publish a high port and put a host reverse proxy in front.</strong> Nginx or Caddy running as a normal system service, listening on 443, proxying to 8443. No kernel tuning, and you probably wanted TLS termination on the host anyway.</li>

<li><strong>Lower the privileged port threshold.</strong> The kernel exposes <code>net.ipv4.ip_unprivileged_port_start</code>, and setting it to 0 lets any user bind any port. It works, and it is a host-wide change that weakens a defence for every process on the machine, not just yours. Understand that before you write it into a playbook.</li>

<li><strong>Run that one container rootful.</strong> Podman supports it. Mixing modes is a legitimate choice as long as it is deliberate and documented rather than something that happened.</li>
</ol>



<h3 class="wp-block-heading">4. Restart on boot</h3>



<p class="wp-block-paragraph">This is the one that catches people who tested on a laptop and shipped to a server. Rootless containers belong to a user session, and a user&#8217;s services are stopped when their last session ends. Reboot the box and nothing comes back, no matter what restart policy you set.</p>



<p class="wp-block-paragraph">The fix is two parts. Enable lingering so the user&#8217;s systemd instance runs without a login, and define the container as a Quadlet unit rather than relying on a restart policy:</p>



<pre class="wp-block-code"><code># Let this user's services run without an active login session.
sudo loginctl enable-linger appuser

# Verify. Linger=yes is the line that matters.
loginctl show-user appuser --property=Linger</code></pre>



<p class="wp-block-paragraph">Then the unit itself. Quadlet files look like systemd units with a Podman-specific section, and a generator turns them into real services at boot and on <code>daemon-reload</code>. Rootless units live in <code>~/.config/containers/systemd/</code>; system ones in <code>/etc/containers/systemd/</code>.</p>



<pre class="wp-block-code"><code># ~/.config/containers/systemd/api.container

[Unit]
Description=Application API
After=network-online.target

[Container]
# Fully qualified. Podman does not assume Docker Hub.
Image=registry.example.com/api:1.4.2
PublishPort=8080:8080
Volume=api-data.volume:/var/lib/api
Environment=NODE_ENV=production

[Service]
Restart=always

[Install]
# Starts at boot once lingering is enabled for this user.
WantedBy=default.target</code></pre>



<pre class="wp-block-code"><code># Regenerate services from Quadlet files, then start it.
systemctl --user daemon-reload
systemctl --user start api

# From here it is an ordinary service.
systemctl --user status api
journalctl --user -u api -f</code></pre>



<p class="wp-block-paragraph">Note that <code>podman generate systemd</code>, which you will find in older tutorials, is deprecated in favour of Quadlet. Podman&#8217;s documentation says it receives urgent bug fixes only and no new features. Do not build anything new on it.</p>



<p class="wp-block-paragraph">One smaller trap while you are here: Podman does not assume Docker Hub for unqualified image names. <code>nginx:alpine</code> may prompt you to choose a registry or fail outright depending on configuration. Write <code>docker.io/library/nginx:alpine</code>, or whatever your registry is, everywhere. This is arguably the correct behaviour and it is still an afternoon of edits.</p>



<h2 class="wp-block-heading">Podman vs Docker: how I would decide</h2>



<p class="wp-block-paragraph">Run these in order and stop at the first one that answers.</p>



<ol class="wp-block-list">
<li><strong>Do you use Swarm, or Docker volume or network plugins?</strong> Stay on Docker. Podman does not implement them and no workaround changes that.</li>

<li><strong>Is a compliance requirement or an access review driving this?</strong> Switch, or at minimum move to rootless. &#8220;The docker group is root&#8221; is a finding you cannot argue with, and rootless Podman is the shortest path to closing it.</li>

<li><strong>Are you on RHEL, Fedora, Rocky or AlmaLinux?</strong> Lean Podman. It is the packaged, supported, tested-by-the-vendor option on those distributions, and you are swimming with the current instead of against it.</li>

<li><strong>Is Docker Desktop licensing a real cost for your organisation?</strong> Check the thresholds against your actual headcount and revenue before assuming either way. If you are over, this is a budget conversation with a free technical answer, which is a rare shape of problem.</li>

<li><strong>Does your test suite or CI depend on the Docker socket?</strong> Prototype that specific dependency against the Podman socket first. It is the highest-risk item and the cheapest to test. If it fails, everything else is academic.</li>

<li><strong>None of the above, and Docker is working?</strong> Stay. &#8220;Podman has nicer architecture&#8221; is true and it is not a business case. Migrations cost weeks, introduce risk, and the containers you are already running do not care which engine started them.</li>
</ol>



<p class="wp-block-paragraph">The honest summary: Podman is the better-designed engine and Docker is the better-supported one. Design wins when you are building something new or when a policy requirement forces the question. Support wins when you have a working system and a limited number of weeks.</p>



<h2 class="wp-block-heading">If you do switch, do it in this order</h2>



<ol class="wp-block-list">
<li><strong>Inventory the socket mounts.</strong> Grep your compose files and CI config for <code>docker.sock</code>. That list is your actual migration scope.</li>

<li><strong>Stand up one non-critical service on Podman.</strong> A staging box, an internal tool, something on a spare VPS. Run it for a fortnight through at least one reboot before you draw conclusions.</li>

<li><strong>Fully qualify every image reference.</strong> Do this in the compose files while they still run under Docker. It changes nothing there and removes a whole class of failure later.</li>

<li><strong>Switch the engine, keep Compose.</strong> Podman socket plus <code>DOCKER_HOST</code>. Change one variable at a time so you know what broke.</li>

<li><strong>Convert to Quadlet last, and only where it earns its place.</strong> Long-lived services benefit. A three-container stack you rebuild constantly is fine staying on Compose.</li>
</ol>



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



<ul class="wp-block-list">
<li><strong>&#8220;Podman is a drop-in replacement.&#8221;</strong> The CLI nearly is. The surrounding tooling is not, and that is where the weeks go.</li>

<li><strong>&#8220;Daemonless means faster.&#8221;</strong> The engine is not your bottleneck. Rootless networking has historically been slower than a root bridge, not faster. Pick Podman for the security model, not for benchmark numbers.</li>

<li><strong>&#8220;Rootless means secure.&#8221;</strong> It means a break-out lands somewhere less catastrophic. A container running rootless with the host filesystem bind-mounted read-write is still a bad idea.</li>

<li><strong>&#8220;Docker is dying.&#8221;</strong> It is not. Docker Engine, Compose and Buildx are actively developed and hosted CI runners still ship them by default.</li>

<li><strong>&#8220;Just alias docker to podman and you&#8217;re done.&#8221;</strong> The alias handles the commands. It does not handle the socket, the ports, the boot behaviour or the image names.</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 Podman run docker-compose files?</h3>



<p class="wp-block-paragraph">Yes, by two routes. Install <code>podman-compose</code>, which is a separate implementation with good but incomplete coverage, or enable the Podman socket and point real Docker Compose at it with <code>DOCKER_HOST</code>. The second gives better specification coverage because you are running the actual Compose tool. Neither is a guarantee; test your own file.</p>



<h3 class="wp-block-heading">Is Podman actually more secure than Docker?</h3>



<p class="wp-block-paragraph">Its default posture is better. Rootless out of the box means no root daemon and no group that is root by another name. Docker can be configured to a similar posture with rootless mode, so the difference is less &#8220;Podman is secure and Docker is not&#8221; and more &#8220;Podman&#8217;s safe path is the easy path&#8221;. Defaults determine what most installations actually run.</p>



<h3 class="wp-block-heading">Do I need to pay for Docker?</h3>



<p class="wp-block-paragraph">Docker Engine on Linux is open source and free. Docker Desktop is the licensed product. Per Docker&#8217;s terms it is free for personal use, education, non-commercial open source, and businesses with fewer than 250 employees and under ten million dollars in annual revenue; cross either threshold, or be a government entity, and commercial use requires a subscription. If you only run Docker Engine on Linux servers, the licensing question does not apply to you.</p>



<h3 class="wp-block-heading">Why do my Podman containers disappear after a reboot?</h3>



<p class="wp-block-paragraph">Because rootless containers run under your user&#8217;s systemd instance, which is torn down when your last session ends. Run <code>loginctl enable-linger</code> for the service account and define the containers as Quadlet units with <code>WantedBy=default.target</code>. A Podman restart policy alone will not survive a reboot.</p>



<h3 class="wp-block-heading">What is Quadlet and do I have to use it?</h3>



<p class="wp-block-paragraph">Quadlet is a systemd generator that turns declarative container definitions into real systemd services. You do not have to use it, but the alternative is hand-writing unit files, and the older <code>podman generate systemd</code> command is deprecated in favour of it. For anything long-lived on a server, Quadlet is the current answer.</p>



<h3 class="wp-block-heading">Can Podman and Docker coexist on the same host?</h3>



<p class="wp-block-paragraph">Yes. They keep separate image and container stores, so nothing collides at the storage layer. Watch for port conflicts if both are running services, and be deliberate about which one a given script is talking to, because <code>DOCKER_HOST</code> and an alias can quietly disagree with each other.</p>



<h3 class="wp-block-heading">Does Podman work with Kubernetes?</h3>



<p class="wp-block-paragraph">Better than Docker does, in the sense that it speaks the format natively. <code>podman kube play</code> runs a Kubernetes manifest on a single host and <code>podman generate kube</code> produces one from running containers. It is not a substitute for a cluster, but as a local development story for a Kubernetes destination it is the closest thing available.</p>



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



<p class="wp-block-paragraph">The Podman vs Docker decision is not really about container engines. Both run OCI images and both will start your workload. It is about whether a root daemon and a root-equivalent group are acceptable in your environment, and whether you have the weeks to spend on the four things that break.</p>



<p class="wp-block-paragraph">If a policy requirement is driving the question, switch and budget properly for the compose and socket work. If you are on an enterprise Linux distribution and starting something new, start on Podman, because you will be swimming with the current. If Docker is working and nothing external is forcing the question, the correct engineering answer is to leave it alone and go fix something that is actually broken.</p>



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



<h2 class="wp-block-heading">Thinking about a migration and want a second opinion?</h2>



<p class="wp-block-paragraph">I help teams decide this question and then execute it without a surprise outage. Things I am usually brought in for:</p>



<ul class="wp-block-list">
<li>Auditing what on your hosts actually depends on the Docker socket, and scoping the migration honestly before anyone commits to it</li>

<li>Testing your real compose files against the Podman socket and reporting what breaks, rather than guessing from a feature table</li>

<li>Converting long-lived services to Quadlet units with proper systemd ordering, health gating and journald logging</li>

<li>Sorting out rootless networking: privileged ports, reverse proxy placement, and the source-IP behaviour that surprises people</li>

<li>Closing the &#8220;docker group equals root&#8221; audit finding, whether that means Podman, rootless Docker, or removing the group entirely</li>

<li>Writing the decision up so it survives the next person who asks why you chose what you chose</li>
</ul>



<p class="wp-block-paragraph">If you are weighing this up, send me a compose file and a list of what mounts the socket. That is usually enough to tell you whether the switch is a fortnight or a quarter.</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/podman-vs-docker-worth-it/">Podman vs Docker: Is the Switch Actually Worth It?</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
