<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: אחיה כהן</title>
    <description>The latest articles on DEV Community by אחיה כהן (@achiya-automation).</description>
    <link>https://dev.to/achiya-automation</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810102%2Fefb43e59-992c-4f8b-91df-ee602c7c853f.jpg</url>
      <title>DEV Community: אחיה כהן</title>
      <link>https://dev.to/achiya-automation</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/achiya-automation"/>
    <language>en</language>
    <item>
      <title>My sudo Logs Hit 10 GB. Session Count Told Me Nothing.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 24 Sep 2026 07:41:26 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-sudo-logs-hit-10-gb-session-count-told-me-nothing-1bn5</link>
      <guid>https://dev.to/achiya-automation/my-sudo-logs-hit-10-gb-session-count-told-me-nothing-1bn5</guid>
      <description>&lt;p&gt;A disk-usage alert sent me looking at &lt;code&gt;/var/log/sudo-io&lt;/code&gt; on a Chatwoot production box. It held 5.9 GB and was adding roughly 654 MB every week. Nothing on that server had changed. No new services, no new admins, no new cron jobs.&lt;/p&gt;

&lt;p&gt;My first instinct was the wrong one: I assumed the server was simply busier than the others. It isn't. It runs the &lt;em&gt;fewest&lt;/em&gt; sudo sessions of any box in the fleet that logs them.&lt;/p&gt;

&lt;p&gt;Here is what the numbers look like, measured live across the whole fleet this morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Two commands. The first counts sessions, the second measures bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# how many sudo sessions were recorded&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find /var/log/sudo-io &lt;span class="nt"&gt;-name&lt;/span&gt; log &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# how much disk those sessions occupy&lt;/span&gt;
&lt;span class="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-sk&lt;/span&gt; /var/log/sudo-io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten hosts in my SSH config. Only four of them have &lt;code&gt;/var/log/sudo-io&lt;/code&gt; at all. The other five never had &lt;code&gt;log_output&lt;/code&gt; enabled, and one was unreachable during the run. That inconsistency is its own finding, and I'll come back to it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Log volume&lt;/th&gt;
&lt;th&gt;Sessions&lt;/th&gt;
&lt;th&gt;KB per session&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;chatwoot&lt;/td&gt;
&lt;td&gt;5.48 GB&lt;/td&gt;
&lt;td&gt;16,168&lt;/td&gt;
&lt;td&gt;356&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;supabase&lt;/td&gt;
&lt;td&gt;2.33 GB&lt;/td&gt;
&lt;td&gt;56,794&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;website&lt;/td&gt;
&lt;td&gt;1.71 GB&lt;/td&gt;
&lt;td&gt;14,289&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chatwoot_admon&lt;/td&gt;
&lt;td&gt;0.49 GB&lt;/td&gt;
&lt;td&gt;8,788&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.02 GB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96,039&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;109&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last two columns together. &lt;code&gt;supabase&lt;/code&gt; recorded &lt;strong&gt;3.5x more sudo sessions&lt;/strong&gt; than &lt;code&gt;chatwoot&lt;/code&gt; and produced &lt;strong&gt;less than half&lt;/strong&gt; the volume. Per session, &lt;code&gt;chatwoot&lt;/code&gt; is 8.3x heavier.&lt;/p&gt;

&lt;p&gt;Session count and log size are not measuring the same thing, and I had been reading the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bytes and sessions diverge
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;log_output&lt;/code&gt; in sudoers does not record &lt;em&gt;which command ran&lt;/em&gt;. It records everything the command writes to its pty. A short admin command that prints two lines costs a few hundred bytes. A command that streams a file costs the size of that file.&lt;/p&gt;

&lt;p&gt;So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;costs almost nothing, while this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /opt/chatwoot-backups/dump-2026-09-14.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;costs 60 MB. The dump gets written to disk a second time, into the audit log, carrying no information the original didn't already have.&lt;/p&gt;

&lt;p&gt;That was the leak. The offsite backup job pulls a database dump every night by reading it through sudo. &lt;code&gt;supabase&lt;/code&gt; and &lt;code&gt;website&lt;/code&gt; already had those reads exempted in &lt;code&gt;/etc/sudoers.d/iolog-exemptions&lt;/code&gt;. &lt;code&gt;chatwoot&lt;/code&gt; did not. &lt;code&gt;chatwoot_admon&lt;/code&gt; had no exemptions file at all. Same fleet, same backup design, three different configurations — because the exemption file was written by hand on each host, one host at a time, and nothing ever checked that they matched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is an exemption, not a shutdown
&lt;/h2&gt;

&lt;p&gt;The tempting fix is to turn &lt;code&gt;log_output&lt;/code&gt; off. Don't. The recording is the point; you want to know what a human typed at 2am on a box holding customer conversations. What you don't want is a byte-for-byte copy of files you already have.&lt;/p&gt;

&lt;p&gt;sudoers lets you carve out specific commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cmnd_Alias IOLOG_EXEMPT = /usr/bin/cat /opt/chatwoot-backups/*, \
                          /usr/bin/stat /opt/chatwoot-backups/*, \
                          /usr/bin/zcat /var/log/caddy/*, \
                          /usr/bin/tail /var/log/caddy/*

Defaults!IOLOG_EXEMPT !log_output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The session still gets logged: who ran what, when. Only the byte stream is dropped.&lt;/p&gt;

&lt;p&gt;Two things worth saying out loud about this file:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate before you apply it.&lt;/strong&gt; &lt;code&gt;visudo -cf /etc/sudoers.d/iolog-exemptions&lt;/code&gt; parses the file without installing it. A syntax error in &lt;code&gt;sudoers.d&lt;/code&gt; can lock you out of sudo on a machine you may only reach through sudo. Keep a timestamped backup next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The paths matter more than the commands.&lt;/strong&gt; Exempting &lt;code&gt;/usr/bin/cat&lt;/code&gt; outright would let anyone pipe any file past the audit log. Exempting &lt;code&gt;cat&lt;/code&gt; &lt;em&gt;restricted to the backup directory&lt;/em&gt; keeps the hole the size of the actual problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ten days later looks like
&lt;/h2&gt;

&lt;p&gt;I applied the exemptions across all four hosts on September 14 and measured again today, September 24.&lt;/p&gt;

&lt;p&gt;At 654 MB/week, &lt;code&gt;chatwoot&lt;/code&gt; should have been near 6.8 GB by now. It reads 5.48 GB. The curve turned over. Two things are doing that work together. New writes stopped, and the 60-day retention cron keeps deleting old sessions, so I'd expect the number to keep falling into mid-October as the pre-fix backlog ages out. I'm not claiming the exemption alone recovered 1.3 GB. I'm claiming the growth stopped, and that's the part I was chasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd actually change
&lt;/h2&gt;

&lt;p&gt;The exemption fixed one symptom. The real defect is that five of ten hosts don't record sudo I/O, two of the four that do had drifted apart, and I only noticed because a disk filled up. A config that exists on some machines and not others isn't a policy, it's a habit that happened to repeat.&lt;/p&gt;

&lt;p&gt;So the check I added isn't "is the log too big." It's this, run across the fleet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# does this host record sudo I/O at all, and is the exemption file present?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; log_output /etc/sudoers /etc/sudoers.d/ 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/sudoers.d/iolog-exemptions &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exemptions: yes"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exemptions: MISSING"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines, and it answers the question the disk alert was too late to ask. This is the same reasoning behind most of the fleet automation I build at &lt;a href="https://achiya-automation.com/services/integrations/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sudo-io-log-growth" rel="noopener noreferrer"&gt;achiya-automation.com&lt;/a&gt; — the alert that fires when a threshold breaks is always a worse version of the check that confirms a configuration is still what you think it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metric to steal
&lt;/h2&gt;

&lt;p&gt;If you have &lt;code&gt;log_output&lt;/code&gt; enabled anywhere, don't look at total size and don't look at session count. Look at bytes per session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'kb=$(du -sk /var/log/sudo-io | cut -f1); \
  n=$(find /var/log/sudo-io -name log -type f | wc -l); \
  echo "$((kb / (n&amp;gt;0?n:1))) KB per session across $n sessions"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across my fleet the number is 109 KB. Anything in that range is ordinary admin work. A host sitting at 350+ has a command streaming a file through sudo, and you can find it in one pass. A host at 43 is doing plenty of work and costing you nothing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;A question for anyone running &lt;code&gt;log_output&lt;/code&gt; in production:&lt;/strong&gt; what's your bytes-per-session number, and did the outlier turn out to be a backup job like mine, or something you'd never have guessed? I'm especially curious whether anyone exempts by &lt;em&gt;path&lt;/em&gt; the way I did here, or whether you found a cleaner pattern than hand-maintained &lt;code&gt;Cmnd_Alias&lt;/code&gt; blocks that drift between hosts.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
      <category>discuss</category>
    </item>
    <item>
      <title>My Monitoring Cron Never Ran Once. crontab -l Showed It Fine.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 20 Sep 2026 06:49:59 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-monitoring-cron-never-ran-once-crontab-l-showed-it-fine-27hd</link>
      <guid>https://dev.to/achiya-automation/my-monitoring-cron-never-ran-once-crontab-l-showed-it-fine-27hd</guid>
      <description>&lt;p&gt;I write monitoring so I don't have to watch things by hand. So there is a special kind of dread in discovering that the monitor you installed to watch production has itself been dead since the day you installed it — and that everything you would normally check said it was fine.&lt;/p&gt;

&lt;p&gt;This one was dead for five days. Here's the autopsy, because the failure mode is boring, extremely common, and almost invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I run WhatsApp automation for businesses, and a session can silently drop: the container stays "up", the health check stays green, but the line stops receiving messages. So I built a small watchdog, &lt;code&gt;waha-session-watch&lt;/code&gt;, that polls the sessions API every ten minutes and alerts if a line goes quiet. It runs as a dedicated unprivileged user, &lt;code&gt;waha&lt;/code&gt;, and needs &lt;code&gt;sudo&lt;/code&gt; for one docker call.&lt;/p&gt;

&lt;p&gt;The crontab line looked completely ordinary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/10 * * * * sudo /usr/local/bin/waha-session-watch &amp;gt;&amp;gt; /opt/waha-watch/cron.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install it, watch &lt;code&gt;cron.log&lt;/code&gt; fill up, move on. Except &lt;code&gt;cron.log&lt;/code&gt; never filled up. And I didn't notice, because I wasn't looking at &lt;code&gt;cron.log&lt;/code&gt;. I was looking at the tool's own log, &lt;code&gt;watch.log&lt;/code&gt;, which had plenty of recent entries. Green across the board.&lt;/p&gt;

&lt;p&gt;Five days later a production line went dark for the better part of a day and nothing alerted. That's when I actually went digging.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first lie: &lt;code&gt;crontab -l&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;crontab &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; waha
&lt;span class="k"&gt;*&lt;/span&gt;/10 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/local/bin/waha-session-watch &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /opt/waha-watch/cron.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. It's installed. &lt;code&gt;crontab -l&lt;/code&gt; printing your line feels like proof the job runs. It is not. It only proves the line is &lt;em&gt;stored&lt;/em&gt;. Whether cron can actually &lt;em&gt;execute&lt;/em&gt; it is a completely separate question, and the answer here was no. Not once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second lie: a log full of runs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;watch.log&lt;/code&gt; had entries. Recent ones. So the tool clearly &lt;em&gt;was&lt;/em&gt; running... right?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-08 21:40:12  OK  7 sessions healthy
2026-09-08 21:52:03  OK  7 sessions healthy
2026-09-08 21:59:47  OK  7 sessions healthy
2026-09-08 22:22:15  OK  7 sessions healthy
2026-09-08 22:32:51  OK  7 sessions healthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the minutes: &lt;code&gt;:40&lt;/code&gt;, &lt;code&gt;:52&lt;/code&gt;, &lt;code&gt;:59&lt;/code&gt;, &lt;code&gt;:22&lt;/code&gt;, &lt;code&gt;:32&lt;/code&gt;. A &lt;code&gt;*/10&lt;/code&gt; cron job fires at &lt;code&gt;:00&lt;/code&gt;, &lt;code&gt;:10&lt;/code&gt;, &lt;code&gt;:20&lt;/code&gt;, &lt;code&gt;:30&lt;/code&gt;, &lt;code&gt;:40&lt;/code&gt;, on the round ten. These timestamps are ragged. Every single one of them was &lt;strong&gt;me&lt;/strong&gt;, running the tool by hand from the terminal on install day to confirm it worked. Not one line in that log came from cron.&lt;/p&gt;

&lt;p&gt;That's the trap in one sentence: &lt;strong&gt;a log full of successful runs is not proof of scheduled runs.&lt;/strong&gt; Manual runs and cron runs land in the same file and look identical unless you read the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tell: one line in syslog
&lt;/h2&gt;

&lt;p&gt;Here's where it actually gives itself up. I grepped the system log for the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;CRON /var/log/syslog | &lt;span class="nb"&gt;grep &lt;/span&gt;waha
... &lt;span class="o"&gt;(&lt;/span&gt;CRON&lt;span class="o"&gt;)&lt;/span&gt; info &lt;span class="o"&gt;(&lt;/span&gt;No MTA installed, discarding output&lt;span class="o"&gt;)&lt;/span&gt;
... CMD &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; /usr/local/bin/waha-session-watch &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /opt/waha-watch/cron.log 2&amp;gt;&amp;amp;1&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;No MTA installed, discarding output&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That message is the whole case. cron ran the job, the job produced output on &lt;strong&gt;stderr&lt;/strong&gt;, and cron tried to email that output to the user, because that's what cron does with anything a job writes when it can't otherwise capture it. There's no mail server, so it threw the text away. Which means: something printed an error, every ten minutes, into a void, for five days.&lt;/p&gt;

&lt;p&gt;If your command's output were really being captured by &lt;code&gt;&amp;gt;&amp;gt; cron.log&lt;/code&gt;, cron would have nothing to mail and this line would never appear. &lt;strong&gt;&lt;code&gt;(No MTA installed, discarding output)&lt;/code&gt; sitting next to a CMD is always a sign the command printed something nobody saw.&lt;/strong&gt; It is one of the highest-signal lines in all of syslog and almost nobody greps for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause: who opens the &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The bug is the redirect. Specifically, &lt;em&gt;who&lt;/em&gt; opens it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo /usr/local/bin/waha-session-watch &amp;gt;&amp;gt; /opt/waha-watch/cron.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's intuitive to read this as "run the whole thing as root and append to the log." That's not what happens. Redirections are set up by the &lt;strong&gt;shell that launches the command, before the command is exec'd&lt;/strong&gt;, and cron runs this line as the &lt;code&gt;waha&lt;/code&gt; user. So the &lt;code&gt;&amp;gt;&amp;gt; /opt/waha-watch/cron.log&lt;/code&gt; is opened with &lt;code&gt;waha&lt;/code&gt;'s permissions, &lt;em&gt;before&lt;/em&gt; &lt;code&gt;sudo&lt;/code&gt; ever runs.&lt;/p&gt;

&lt;p&gt;And the directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ld&lt;/span&gt; /opt/waha-watch
drwxr-xr-x 2 root root 4096 Sep  8 /opt/waha-watch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;root:root&lt;/code&gt;, mode &lt;code&gt;755&lt;/code&gt;. The &lt;code&gt;cron.log&lt;/code&gt; file didn't exist yet, and &lt;code&gt;waha&lt;/code&gt; has no write permission in a &lt;code&gt;755&lt;/code&gt; directory it doesn't own. So the shell can't create the file, the redirection fails, the shell bails out &lt;strong&gt;before executing &lt;code&gt;sudo&lt;/code&gt;&lt;/strong&gt;, and the command never runs at all. cron dutifully mails the "permission denied" to nobody.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo&lt;/code&gt; was never the problem. The unprivileged redirect target in front of it was. The privilege you granted the command is irrelevant when the shell dies setting up its plumbing first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Anticlimactic, as these always are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo touch&lt;/span&gt; /opt/waha-watch/cron.log
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;waha:waha /opt/waha-watch/cron.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the log file up front, owned by the user cron runs as. First round-numbered timestamp showed up ten minutes later and the watchdog has been real ever since. No code changed. The script was always fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed
&lt;/h2&gt;

&lt;p&gt;The fix took ten seconds. The lesson took five days, so it's worth more than the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After installing any cron job or systemd timer, verify the first &lt;em&gt;scheduled&lt;/em&gt; run in two independent places:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The scheduler's own record — &lt;code&gt;grep CRON /var/log/syslog&lt;/code&gt; and confirm a &lt;code&gt;CMD&lt;/code&gt; line &lt;strong&gt;with no &lt;code&gt;discarding output&lt;/code&gt; next to it&lt;/strong&gt;, at a round-numbered minute.&lt;/li&gt;
&lt;li&gt;The tool's own log — an entry whose timestamp lands on the schedule, not a ragged manual one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If those two don't agree, you have a job that is stored but not running. &lt;code&gt;crontab -l&lt;/code&gt; is not on this list on purpose: it can only ever confirm storage.&lt;/p&gt;

&lt;p&gt;And the redirect rule specifically: &lt;strong&gt;if a scheduled command redirects into a log, create that file ahead of time, owned by the user the scheduler runs as.&lt;/strong&gt; Don't assume &lt;code&gt;sudo&lt;/code&gt; on the command buys you anything for the &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;. That file is opened by the caller, not the callee.&lt;/p&gt;

&lt;p&gt;I build this kind of infrastructure for a living — production WhatsApp automation for Israeli businesses at &lt;a href="https://achiya-automation.com/services/whatsapp-bot/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=cron-silent-death-redirect" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt; — and this watchdog exists precisely so a silent line doesn't go unnoticed. The irony of the watchdog itself going silent for five days is not lost on me. That's exactly why the two-place verification is now non-negotiable in my install checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;What's the longest one of your "installed" monitors has been quietly dead before you noticed, and what finally gave it away? I'm collecting the tells that actually work in the wild, beyond the syslog one above. Drop yours below. #discuss&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>cron</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>I Audited 38 Production Containers. Zero Use depends_on restart.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:52:44 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-audited-38-production-containers-zero-use-dependson-restart-3a2o</link>
      <guid>https://dev.to/achiya-automation/i-audited-38-production-containers-zero-use-dependson-restart-3a2o</guid>
      <description>&lt;p&gt;My n8n upgrade runbook has a line in it that I wrote by hand, sometime in 2026:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;After the main container reports healthy, run &lt;code&gt;docker restart n8n-docker-caddy-n8n-worker-1&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have run that line at every version bump for months. It works. I never questioned it.&lt;/p&gt;

&lt;p&gt;Last week I finally read the Compose specification closely enough to notice that I had hand-implemented a field that has existed since Compose &lt;strong&gt;2.17.0&lt;/strong&gt;, released in 2023. The field is &lt;code&gt;depends_on.&amp;lt;service&amp;gt;.restart&lt;/code&gt;. My runbook is a human being standing in for one boolean.&lt;/p&gt;

&lt;p&gt;So I went and measured how widespread the gap actually is — not in a demo repo, in the fleet that runs my clients' work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measurement
&lt;/h2&gt;

&lt;p&gt;Eight servers, read-only, on 2026-09-17. I pulled two things from every running container: its healthcheck configuration, and the &lt;code&gt;com.docker.compose.depends_on&lt;/code&gt; label that Compose v2 writes at create time. That label is the ground truth — it records the resolved dependency as &lt;code&gt;service:condition:restart&lt;/code&gt;, so I didn't have to trust the YAML on disk matching what is actually running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;c &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Names}}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;docker inspect &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'{{index .Config.Labels "com.docker.compose.project"}}|{{.Name}}|{{index .Config.Labels "com.docker.compose.depends_on"}}'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results across &lt;strong&gt;38 running containers&lt;/strong&gt;, in &lt;strong&gt;14 Compose projects&lt;/strong&gt;, on &lt;strong&gt;8 servers&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Containers with a healthcheck&lt;/td&gt;
&lt;td&gt;33 / 38&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Containers with any &lt;code&gt;depends_on&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7 / 38&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Of those 7, using &lt;code&gt;condition: service_healthy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;7 / 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Of those 7, using &lt;code&gt;restart: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every dependency I had bothered to declare was declared &lt;em&gt;well&lt;/em&gt; — all seven wait for health, none of them settle for &lt;code&gt;service_started&lt;/code&gt;. And every single one of them ends in &lt;code&gt;:false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That trailing &lt;code&gt;false&lt;/code&gt; is the restart flag. It means: when Compose updates the dependency, do not restart me.&lt;/p&gt;

&lt;p&gt;The 31 containers with no &lt;code&gt;depends_on&lt;/code&gt; at all are a separate confession, and I'll come back to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What depends_on actually promises
&lt;/h2&gt;

&lt;p&gt;The long syntax takes three sub-options, and it is worth being precise about the scope of each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;condition: service_started&lt;/code&gt; — the dependency's container has been created and started. Says nothing about the process inside it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;condition: service_healthy&lt;/code&gt; — the dependency's healthcheck has passed. This is the one everybody reaches for.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;condition: service_completed_successfully&lt;/code&gt; — the dependency ran to exit 0. For migration jobs and seeders.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;restart: true&lt;/code&gt; — &lt;em&gt;"Compose restarts this service after it updates the dependency service."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;required: false&lt;/code&gt; — downgrade a missing dependency from an error to a warning.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read the &lt;code&gt;restart&lt;/code&gt; line again. It is scoped to &lt;strong&gt;Compose-controlled updates&lt;/strong&gt; — the &lt;code&gt;docker compose up -d&lt;/code&gt; that replaces a container with a new image. It is explicitly not about runtime crashes; your restart policy covers those.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;condition&lt;/code&gt; and &lt;code&gt;restart&lt;/code&gt; answer two genuinely different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;condition&lt;/strong&gt; → in what order do these containers come up &lt;em&gt;the first time&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;restart&lt;/strong&gt; → what happens to me when the thing underneath me gets &lt;em&gt;replaced&lt;/em&gt;?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A stack with &lt;code&gt;condition: service_healthy&lt;/code&gt; everywhere and &lt;code&gt;restart&lt;/code&gt; nowhere is fully ordered on a cold boot and fully unordered on every deploy after that. Which, on a server that boots once a year and deploys twice a month, means the feature is protecting the rare case and sitting out the common one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where condition alone was not enough
&lt;/h2&gt;

&lt;p&gt;Two dated incidents from this fleet, both of which I originally filed as something other than what they were.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2026-07-31 — the healthy database that wasn't ready.&lt;/strong&gt; Upgrading n8n from 2.30.6 to 2.32.6 on the queue-mode server. The worker's declared dependency reads, to this day, &lt;code&gt;postgres:service_healthy,redis:service_healthy&lt;/code&gt;. Postgres was genuinely healthy. The worker still died — stack trace in &lt;code&gt;MigrationExecutor.executePendingMigrations&lt;/code&gt;, then &lt;code&gt;unhealthy&lt;/code&gt; and stuck there.&lt;/p&gt;

&lt;p&gt;The reason is that &lt;code&gt;service_healthy&lt;/code&gt; asked Postgres whether it was accepting connections, and Postgres honestly said yes. Nobody asked the question that mattered: &lt;em&gt;has the schema been migrated to the new version yet?&lt;/em&gt; The main container and the worker both came up against the same database and both started running migrations. Main won. The worker crashed on a half-applied schema.&lt;/p&gt;

&lt;p&gt;There is no healthcheck you can write on Postgres that answers "is the application's schema current", because the answer doesn't live in Postgres — it lives in whichever sibling container is doing the migrating. The fix I actually deployed was that runbook line: wait for main, restart the worker. Which is &lt;code&gt;depends_on: { n8n: { condition: service_healthy, restart: true } }&lt;/code&gt; performed manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2026-09-04 — the update that kept everything running and broke one thing anyway.&lt;/strong&gt; Docker engine 29.7.2 → 29.8.0 across the fleet. &lt;code&gt;live-restore&lt;/code&gt; did its job: every container survived, 8→8, 5→5, 3→3. Except &lt;code&gt;supabase-docker-proxy&lt;/code&gt;, which went &lt;code&gt;unhealthy&lt;/code&gt; with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR Docker API proxy error error="dial unix /var/run/docker.sock: connect: connection refused"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It mounts &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. Replacing the daemon replaces the socket; the process inside kept holding the old one and never reconnected. &lt;code&gt;live-restore&lt;/code&gt; keeps the &lt;em&gt;container&lt;/em&gt; running — it cannot heal a client that lost its connection to the API.&lt;/p&gt;

&lt;p&gt;The tell was ugly: &lt;code&gt;docker ps&lt;/code&gt; showed &lt;code&gt;Up 3 days (unhealthy)&lt;/code&gt;. The uptime was true. It really had not fallen over. &lt;code&gt;FailingStreak&lt;/code&gt; was 75, and that was the number that said when it actually broke. A &lt;code&gt;docker restart&lt;/code&gt; fixed it in fifteen seconds.&lt;/p&gt;

&lt;p&gt;Note what both incidents have in common. Neither container crashed. Both needed to be restarted &lt;em&gt;because something underneath them changed&lt;/em&gt;, and in both cases the thing that noticed was a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 31 with no depends_on
&lt;/h2&gt;

&lt;p&gt;The honest part of the audit. Four of them are application containers in production: the Rails and Sidekiq containers of two self-hosted Chatwoot instances. They have no declared dependency on their own Postgres or Redis. On a cold boot they race, lose, crash, and get resurrected by &lt;code&gt;restart: always&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That works. It has worked for a year. It is also the reason that stack's real recovery time is invisible — the recovery is a crash loop that happens to terminate, and nothing records how long it took.&lt;/p&gt;

&lt;p&gt;While I was in there I measured the other number that deploys care about. Those Rails containers run &lt;code&gt;start_period: 60s&lt;/code&gt;, &lt;code&gt;interval: 60s&lt;/code&gt;, &lt;code&gt;retries: 3&lt;/code&gt;. Add it up: a Rails container that comes back up broken can present as &lt;code&gt;starting&lt;/code&gt;, then &lt;code&gt;healthy&lt;/code&gt;-adjacent, for up to &lt;strong&gt;four minutes&lt;/strong&gt; before Docker is willing to call it &lt;code&gt;unhealthy&lt;/code&gt;. If your deploy script polls health to decide whether to roll back, that is the size of your blind spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;For the dependencies that already exist, adding one line each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;n8n-worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;n8n&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
        &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;     &lt;span class="c1"&gt;# ← replace main, and I get restarted after it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two changes in that block, not one. The &lt;code&gt;restart: true&lt;/code&gt; is the field I had been simulating by hand. The new &lt;code&gt;n8n&lt;/code&gt; entry is the fix for the 2026-07-31 incident: the worker's real dependency was never Postgres, it was &lt;em&gt;whoever migrates Postgres&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Run this on your own fleet before you assume you're clean — it reads the resolved labels, not the YAML you think is deployed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;c &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Names}}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker inspect &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{index .Config.Labels "com.docker.compose.depends_on"}}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$d&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything ending in &lt;code&gt;:false&lt;/code&gt; is a container that will keep running against a dependency you just replaced.&lt;/p&gt;

&lt;p&gt;Two caveats I'd rather state than have corrected in the comments. First, &lt;code&gt;restart: true&lt;/code&gt; is not free — it widens each deploy, because more containers cycle. On a stack where the dependency changes often, that is the trade you are making. Second, it is still not zero-downtime; it is &lt;em&gt;ordered&lt;/em&gt; downtime. Real zero-downtime means two versions serving at once behind a proxy, and Compose is not the tool for that. What this fixes is the cheaper, more common failure: the deploy that finishes green while something behind it is quietly talking to a socket that no longer exists.&lt;/p&gt;

&lt;p&gt;Most of the infrastructure work at &lt;a href="https://achiya-automation.com/services/custom-solutions/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=docker-compose-zero-downtime" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt; looks like this — not clever, just the boring field nobody read the docs for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;I'm curious whether the &lt;code&gt;:false&lt;/code&gt; ratio is a me problem or an everyone problem. Run the loop above and post your two numbers: &lt;strong&gt;how many of your running containers declare &lt;code&gt;depends_on&lt;/code&gt; at all, and how many of those end in &lt;code&gt;:true&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And if you're above zero on the second number — what made you go looking? I only found it because I noticed I'd written the feature into a runbook by hand.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>compose</category>
      <category>reliability</category>
    </item>
    <item>
      <title>I Measured 27,257 MCP Connections. The p90 Session Waits 35s.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:45:26 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-measured-27257-mcp-connections-the-p90-session-waits-35s-55dp</link>
      <guid>https://dev.to/achiya-automation/i-measured-27257-mcp-connections-the-p90-session-waits-35s-55dp</guid>
      <description>&lt;p&gt;In July I wrote a 56-line shell script called &lt;code&gt;mcp-optional&lt;/code&gt; whose entire job was to &lt;strong&gt;remove&lt;/strong&gt; two MCP servers from my config.&lt;/p&gt;

&lt;p&gt;Not fix them. Remove them.&lt;/p&gt;

&lt;p&gt;Both were stdio servers, so each one spawns its own Node process &lt;em&gt;per session&lt;/em&gt;. With around 13 Claude Code sessions open on a 16GB M4, that was roughly 1.4GB of duplicated RAM, which meant swap thrash, which meant a hot laptop. Disabling them by default and re-enabling on demand fixed it.&lt;/p&gt;

&lt;p&gt;I filed that under "memory problem, solved" and stopped thinking about it.&lt;/p&gt;

&lt;p&gt;This morning, chasing something unrelated, I started reading the MCP connection logs Claude Code leaves on disk. It turns out one of those two servers wasn't just the RAM tax. It's also the slowest thing I connect to, by a wide margin — and the reason isn't its code at all.&lt;/p&gt;

&lt;p&gt;Here's what 35 days of logs actually say.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I measured
&lt;/h2&gt;

&lt;p&gt;Claude Code writes a JSONL log per MCP server, per session, under &lt;code&gt;~/Library/Caches/claude-cli-nodejs/&amp;lt;project&amp;gt;/mcp-logs-&amp;lt;server&amp;gt;/&lt;/code&gt;. Two lines matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Starting connection with timeout of 30000ms
Connection established with capabilities: {"hasTools":true,...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair them inside one file and you get a real connection latency. I did that across every project directory on this machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;33,599 log files&lt;/strong&gt;, 2026-08-13 → 2026-09-16 (35 days)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;38,876&lt;/strong&gt; connection attempts started&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;27,257&lt;/strong&gt; clean start→established pairs, across &lt;strong&gt;22 servers&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2,888&lt;/strong&gt; sessions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is one developer's machine, not a lab. That's the point — it's the distribution you actually live in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headline numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;percentile&lt;/th&gt;
&lt;th&gt;connect time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;median&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;582 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p75&lt;/td&gt;
&lt;td&gt;1,650 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p90&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,678 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;6,428 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14,049 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The median looks great. Under six tenths of a second — nobody would ever file a bug.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;39% of connections take longer than a second&lt;/strong&gt;, and the p99 is fourteen seconds. Against a client timeout of 30,000ms, the tail is not a rounding error. It's most of the way to the wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause #1: it's OAuth, not your server
&lt;/h2&gt;

&lt;p&gt;This is the finding that reframed the whole thing for me. I split every connection by whether a token refresh happened inside that same connection window:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;median&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;token refresh in window&lt;/td&gt;
&lt;td&gt;748&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,170 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9,619 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no refresh&lt;/td&gt;
&lt;td&gt;26,509&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;550 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3,400 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;5.8× slower.&lt;/strong&gt; Same servers, same network, same machine. The only difference is whether the client had to go get a new access token first.&lt;/p&gt;

&lt;p&gt;Your MCP server didn't take three seconds to start. Your MCP server took 550ms to start, and an OAuth round-trip took the other 2.6 seconds while the server sat there doing nothing.&lt;/p&gt;

&lt;p&gt;That's why "why is this server slow" is usually the wrong question. Only 2.7% of my connections hit a refresh — but that 2.7% is where a disproportionate share of the visible pain lives, because it's the path that turns a fast connection into a slow one non-deterministically. You can't reproduce it on demand, so you blame the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause #2: transport, with a twist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;transport&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;median&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP&lt;/td&gt;
&lt;td&gt;19,894&lt;/td&gt;
&lt;td&gt;676 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,395 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stdio&lt;/td&gt;
&lt;td&gt;7,363&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;222 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,693 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;stdio is 3× faster at the median — no surprise, there's no network. But look at p90: &lt;strong&gt;stdio is worse in the tail.&lt;/strong&gt; Spawning a process has a floor of roughly nothing and a ceiling of "npm decided to do something." An HTTP server that's already running is slower on average and far more predictable.&lt;/p&gt;

&lt;p&gt;If you're choosing a transport, that trade is the actual decision. Not "stdio is faster."&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause #3: nobody connects to one server
&lt;/h2&gt;

&lt;p&gt;Per-connection numbers hide the thing that actually costs you time, which is fan-out. Per session on this machine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;median 8&lt;/strong&gt; MCP servers, max 16&lt;/li&gt;
&lt;li&gt;median total connect time: &lt;strong&gt;5.9 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;p90: &lt;strong&gt;35.5 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;p99: &lt;strong&gt;81.8 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;33.7%&lt;/strong&gt; of sessions spend more than 10 seconds just connecting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That p90 is the number that made me stop and re-read my own script. A third of the time I open a session, I'm waiting a meaningful fraction of a minute before the first token — not because any single server is broken, but because eight of them each drew from that fat tail and nothing amortizes.&lt;/p&gt;

&lt;p&gt;Worst offender by median, unsurprisingly: one of the two servers I'd already disabled for RAM reasons, at &lt;strong&gt;2,261 ms median and 10,413 ms at p90&lt;/strong&gt;. I removed it in July for the wrong reason and got the right outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 29.7% nobody talks about
&lt;/h2&gt;

&lt;p&gt;Of 38,876 connection attempts, &lt;strong&gt;11,556 never logged an established line at all&lt;/strong&gt; — 29.7%.&lt;/p&gt;

&lt;p&gt;Some of that is log rotation cutting a file mid-handshake, so treat it as an upper bound rather than a failure rate. But it isn't evenly spread: a single remote connector accounts for 7,422 of them on its own. That's not noise, that's one integration failing over and over while everything upstream stays quiet about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I almost published
&lt;/h2&gt;

&lt;p&gt;My first pass had a max connect time of &lt;strong&gt;119 seconds&lt;/strong&gt; and a p99 of 14.5s, and I nearly wrote a paragraph about it.&lt;/p&gt;

&lt;p&gt;Then I re-read the client's own log line: &lt;code&gt;timeout of 30000ms&lt;/code&gt;. A connection cannot establish at 119 seconds if the client gives up at 30. Those samples weren't slow connections — they were a closed laptop. The wall clock kept running through sleep; the connection didn't.&lt;/p&gt;

&lt;p&gt;So I capped every measurement at the client's declared 30s timeout and threw the rest out. That removed &lt;strong&gt;24 of 27,281 samples — 0.09%&lt;/strong&gt;. Every number above is post-cap.&lt;/p&gt;

&lt;p&gt;I mention it because the uncapped version would have been a better story and a false one. If you run this on your own machine, cap it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually helps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count your servers before you tune any of them.&lt;/strong&gt; Median 8, p90 35 seconds. Removing one mediocre server beats optimizing a good one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make heavy servers opt-in.&lt;/strong&gt; That's all &lt;code&gt;mcp-optional&lt;/code&gt; does — &lt;code&gt;claude mcp remove&lt;/code&gt; by default, &lt;code&gt;claude mcp add&lt;/code&gt; when a task needs it. It's the highest-leverage 56 lines I have written this year, and I wrote it for the wrong reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at your refresh path, not your server code.&lt;/strong&gt; If connections are erratic rather than uniformly slow, that's the OAuth signature: 550ms most of the time, 3.2s when the token expired.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer an already-running HTTP server for anything heavy.&lt;/strong&gt; Worse median, much better p90.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the logs you already have.&lt;/strong&gt; Nothing here required instrumentation. It was sitting in a cache directory the whole time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason I care about any of this: at &lt;a href="https://achiya-automation.com/services/ai-agents/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp-server-startup-time" rel="noopener noreferrer"&gt;Achiya Automation&lt;/a&gt; I run agent tooling against real client infrastructure all day, and a 35-second session start that lands on one session in ten — with a third of them over 10 seconds — is a tax I pay dozens of times a day without ever seeing it on a bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question
&lt;/h2&gt;

&lt;p&gt;If you've got MCP servers configured right now — &lt;strong&gt;how many, and when did you last check whether you still use all of them?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd genuinely like to know whether median-8 is normal or whether I'm the outlier. And if you've measured a refresh-vs-no-refresh split on a different client, I want to see that number, because I only have one machine to look at.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>3 of 7 WhatsApp Lines Died in 9 Days. Docker Said 'Healthy'.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 14 Sep 2026 05:47:45 +0000</pubDate>
      <link>https://dev.to/achiya-automation/3-of-7-whatsapp-lines-died-in-9-days-docker-said-healthy-6le</link>
      <guid>https://dev.to/achiya-automation/3-of-7-whatsapp-lines-died-in-9-days-docker-said-healthy-6le</guid>
      <description>&lt;p&gt;At 08:33 this morning I ran &lt;code&gt;GET /api/sessions&lt;/code&gt; against my own WhatsApp gateway and read the line I least wanted to read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"session_&amp;lt;redacted&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FAILED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"me"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pushName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;insurance agency&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;redacted&amp;gt;@c.us"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"activity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1789052954786&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That timestamp is 10 September, 18:09. The check ran on 14 September. The line had been dead for eighty-six hours, and every dashboard I own was green.&lt;/p&gt;

&lt;p&gt;I run WhatsApp automation for Israeli small businesses. Each client's business number lives as a session inside a self-hosted &lt;a href="https://waha.devlike.pro/" rel="noopener noreferrer"&gt;WAHA&lt;/a&gt; container, which bridges their line into a CRM and a pile of n8n workflows. Seven sessions, seven businesses, one container.&lt;/p&gt;

&lt;p&gt;Between 4 and 13 September, three of those seven lines went down. Here is what each one taught me, and what I check now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The health check was answering the wrong question
&lt;/h2&gt;

&lt;p&gt;My monitor checked that the &lt;code&gt;waha&lt;/code&gt; container was running and responsive. It was. It stayed running, responsive and &lt;code&gt;healthy&lt;/code&gt; through all three outages, because a container that is perfectly happy to serve you a list of dead sessions is, by every definition Docker cares about, working.&lt;/p&gt;

&lt;p&gt;This is the ordinary liveness-versus-readiness mistake, except the readiness that mattered was not the service's. It was the readiness of seven independent things &lt;em&gt;inside&lt;/em&gt; the service, each of which can fail on its own, for unrelated reasons, without the process noticing or caring.&lt;/p&gt;

&lt;p&gt;One client's line failed on 4 September at 20:47. I found it on the 8th. Three and a half days, during which the container reported itself healthy roughly five hundred times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that looks like nothing at all
&lt;/h2&gt;

&lt;p&gt;The second one is the reason I am writing this.&lt;/p&gt;

&lt;p&gt;On 5 September at 11:04 the gateway logged a &lt;code&gt;device removed stream error&lt;/code&gt; for a clinic's line. The linked device had been removed from the client's phone — deliberately, or while clearing out old sessions. I never found out which, and it does not matter.&lt;/p&gt;

&lt;p&gt;What matters is what the clinic saw afterwards: nothing. Messages kept arriving on the owner's phone exactly as before. Customers got replies, because a human was reading the phone. From the business's side the line was completely, visibly fine.&lt;/p&gt;

&lt;p&gt;Only the CRM went blind. Only the automations stopped firing. The reminders that go out before appointments, the routing, the logging, the follow-ups — all silently not happening, behind a line that looked perfectly alive to the only person in a position to notice.&lt;/p&gt;

&lt;p&gt;If your failure mode is invisible to the user &lt;em&gt;and&lt;/em&gt; invisible to your health check, you do not have a monitoring gap. You have no monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  You cannot always tell whose line just died
&lt;/h2&gt;

&lt;p&gt;When I sat down to write the alerting, I hit a problem I had not anticipated: a dead session may not be able to tell you who it belongs to.&lt;/p&gt;

&lt;p&gt;While a session is &lt;code&gt;WORKING&lt;/code&gt;, &lt;code&gt;/api/sessions&lt;/code&gt; gives you &lt;code&gt;me.id&lt;/code&gt; (the number) and &lt;code&gt;me.pushName&lt;/code&gt; (the business name). When a session is torn down by an unlink, those fields are wiped. The identity disappears at precisely the moment you need it to address an alert.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because I wrote this down as an absolute rule in my own notes last week and it is not one. Checking the live API this morning while drafting this post, the currently-failed insurance line &lt;em&gt;does&lt;/em&gt; still carry its &lt;code&gt;me&lt;/code&gt; block, as you can see in the JSON above. So it depends on how the session died: a removed device wipes the identity, a connection-level failure does not.&lt;/p&gt;

&lt;p&gt;Which is worse than either rule on its own, because it means you cannot depend on the field being there. The fix is the same either way: keep your own roster. My watcher rewrites a &lt;code&gt;roster.json&lt;/code&gt; on every pass — but only from sessions currently in &lt;code&gt;WORKING&lt;/code&gt;, because a single pass during an outage would otherwise overwrite good identity with nulls.&lt;/p&gt;

&lt;p&gt;And you cannot recover the mapping from billing, either. I scanned all 141 customers in our accounting system looking for these business numbers. Zero matches. What sits in an invoice is the owner's personal mobile, which is a different number from the business line the bot runs on. The mapping is manual, or it does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The watchdog that never ran
&lt;/h2&gt;

&lt;p&gt;On 8 September I built the thing that should have caught all of this: a watcher on a ten-minute cron that diffs session states, notifies the business owner when their own line drops, and sends them a pairing code to reconnect it themselves.&lt;/p&gt;

&lt;p&gt;I tested it. Ten test scenarios, three real bugs caught before install. I watched it run. I marked the problem solved.&lt;/p&gt;

&lt;p&gt;It did not run once from cron until 13 September.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/10 * * * * sudo /usr/local/bin/waha-session-watch &amp;gt;&amp;gt; /opt/waha-watch/cron.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/opt/waha-watch&lt;/code&gt; is owned by &lt;code&gt;root&lt;/code&gt;, mode 755. The cron user is &lt;code&gt;waha&lt;/code&gt;. The redirect is evaluated by the shell &lt;em&gt;before&lt;/em&gt; &lt;code&gt;sudo&lt;/code&gt; runs, as the unprivileged user, so creating &lt;code&gt;cron.log&lt;/code&gt; failed, so the shell exited, so the command never executed at all. Cron dutifully tried to mail me the error and found no MTA installed, so it dropped it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(CRON) info (No MTA installed, discarding output)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five days of no monitoring, hidden behind a crontab entry that reads perfectly.&lt;/p&gt;

&lt;p&gt;What fooled me during the install was &lt;code&gt;watch.log&lt;/code&gt;. It had runs in it — 21:40, 21:52, 21:59, 22:22. Non-round minutes, every one of them, because they were my own manual test runs. Scheduled runs land on round minutes. I looked at a log full of evidence that the tool worked and read it as evidence that the &lt;em&gt;schedule&lt;/em&gt; worked.&lt;/p&gt;

&lt;p&gt;The insurance line fell on the 10th, into that exact blind window. The first genuinely scheduled run, on the night of the 13th, found it immediately — three days late.&lt;/p&gt;

&lt;p&gt;The fix was &lt;code&gt;touch cron.log &amp;amp;&amp;amp; chown waha:waha&lt;/code&gt;. That is the entire fix. The lesson costs more than the fix: &lt;strong&gt;that &lt;code&gt;No MTA installed, discarding output&lt;/code&gt; line in syslog is never noise.&lt;/strong&gt; It means your scheduled command printed something nobody has ever read.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;Four things changed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health-check the units, not the process.&lt;/strong&gt; The container being up says nothing about the seven sessions inside it. Whatever your service multiplexes — tenants, sessions, connections, device links — that is the thing with a state worth alerting on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify the first scheduled run, in two places.&lt;/strong&gt; Not "is it in &lt;code&gt;crontab -l&lt;/code&gt;", which was true and meaningless for five days. Check syslog for the &lt;code&gt;CMD&lt;/code&gt; line, and check the tool's own log for an entry on a round minute. If a cron entry redirects to a file, create the file first, owned by the cron user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep a persistent roster, written only from healthy state.&lt;/strong&gt; Never let a failure pass overwrite identity you will need to send the alert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alert the person who can actually fix it.&lt;/strong&gt; A dropped line is repaired by someone holding the phone, not by me. The watcher now messages the business owner directly with a pairing code, inside working hours, once per incident. The one-message-per-incident rule matters as much as the alert: the fastest way to get a monitoring channel ignored is to send the same thing every ten minutes.&lt;/p&gt;

&lt;p&gt;It matters that this is WhatsApp specifically. Bezeq's 2025 Internet Report — the annual survey of Israeli internet use, published in December 2025 — &lt;a href="https://www.bezeq.co.il/bloghome/Digital/internetreport_2025/" rel="noopener noreferrer"&gt;notes in passing&lt;/a&gt; that reaching 90% WhatsApp usage in Israel took about a decade. For a small business here, that line is not a channel. It is the front door. Which is exactly why &lt;a href="https://achiya-automation.com/services/whatsapp-bot/customer-service/" rel="noopener noreferrer"&gt;the customer-service bots running on those lines&lt;/a&gt; need a liveness signal independent of the phone in the owner's pocket, because the owner's pocket will never report the outage. It does not look like one from there.&lt;/p&gt;

&lt;p&gt;Three lines, nine days, one container that never once said anything was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I am actually asking
&lt;/h2&gt;

&lt;p&gt;I want to hear about the second category specifically — not the outage your monitoring missed, but the one where &lt;strong&gt;the user experience stayed completely normal while the system underneath was dead&lt;/strong&gt;, so nobody had any reason to report it.&lt;/p&gt;

&lt;p&gt;What was the failure, and what signal finally caught it? I am collecting these, because I suspect the honest answer for most of them is "a human noticed weeks later," and I would like to be proven wrong.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>monitoring</category>
      <category>postmortem</category>
      <category>whatsapp</category>
    </item>
    <item>
      <title>My safety guard was checked in 6 places. Nothing ever set it.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Fri, 11 Sep 2026 06:43:45 +0000</pubDate>
      <link>https://dev.to/achiya-automation/my-safety-guard-was-checked-in-6-places-nothing-ever-set-it-55cd</link>
      <guid>https://dev.to/achiya-automation/my-safety-guard-was-checked-in-6-places-nothing-ever-set-it-55cd</guid>
      <description>&lt;p&gt;A user of my Safari MCP server — the user was me, from a scheduled run — hit a failure I could not talk my way out of.&lt;/p&gt;

&lt;p&gt;Right after the background daemon restarted, the server opened a browser tab and handed back a capability token for it. The token is how every later command proves it is allowed to touch that tab, because the whole point of the ownership system is that the server must never act on a tab &lt;em&gt;you&lt;/em&gt; opened. Thirty-three seconds later, the very next command was refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tab safety: receipt is forged, stale, ambiguous, or not valid for this origin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forged. By me. For a tab I had just opened, four lines earlier.&lt;/p&gt;

&lt;p&gt;Worse, it was unrecoverable from the client side. The token could not be rotated. Switching to the tab by index was refused — "not opened by this MCP session." Closing it was refused too, which is &lt;em&gt;correct&lt;/em&gt;, since closing is the one action you cannot undo and ownership could no longer be proven. So the tab sat there, stranded and unusable, and the agent had to reopen its work somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling things out
&lt;/h2&gt;

&lt;p&gt;I had two obvious hypotheses and both died in the same session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"A newer tab invalidates the older token."&lt;/strong&gt; Minted a token, opened a second tab, used the first token afterwards. Worked fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Switching by index corrupts the next command."&lt;/strong&gt; Switched by index, ran a command with no token at all. Worked fine.&lt;/p&gt;

&lt;p&gt;The one thing every failure had in common was timing. Everything opened once the daemon had been up for a couple of minutes behaved perfectly. Only the tabs created inside the restart window went bad. The first tab creation in that window took &lt;strong&gt;22 seconds&lt;/strong&gt; — the extension's background worker was still cold-starting — and that number turned out to be the entire story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual mechanism
&lt;/h2&gt;

&lt;p&gt;Tokens are stamped with a browser-session epoch: a random identifier that ties a token to one continuous run of the browser. If the browser restarts, the epoch rotates, and every old token is correctly rejected instead of being allowed to point at a recycled tab id. Good design. The epoch lives in session storage, which is the authority.&lt;/p&gt;

&lt;p&gt;Here is the function that reads it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_ensureBrowserSessionEpoch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;_browserSessionStorageAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Browser session storage is unavailable; refusing durable tab authority&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-f0-9&lt;/span&gt;&lt;span class="se"&gt;]{36}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_browserSessionEpoch&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_browserSessionEpoch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ... otherwise read storage, or mint and persist a new epoch&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last &lt;code&gt;if&lt;/code&gt; is the problem. Once the worker has resolved an epoch into memory, it never consults storage again for the rest of its life. The cached copy is treated as the truth.&lt;/p&gt;

&lt;p&gt;So when the epoch rotates in storage while the worker is still alive — which is exactly what a daemon restart plus an extension re-handshake does — the worker keeps stamping brand-new tokens with the &lt;strong&gt;old&lt;/strong&gt; epoch, and writes them to durable storage that way. Nothing complains, because nothing is looking.&lt;/p&gt;

&lt;p&gt;The complaint arrives later, from the one path that &lt;em&gt;does&lt;/em&gt; read storage. When the worker eventually cold-starts, it rehydrates its tables and filters the stored tokens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receiptsFresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;receiptEnvelope&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;receiptEnvelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;_TAB_RECEIPTS_VERSION&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;receiptEnvelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browserEpoch&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;browserEpoch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stored record carries the stale epoch. &lt;code&gt;receiptsFresh&lt;/code&gt; is false. The entire record array is discarded, the in-memory tables are cleared and repopulated from that empty set, and every token minted during the stale window dies at the same instant. Which is precisely what the report showed: two tabs, created five seconds apart, rejected together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually bothers me
&lt;/h2&gt;

&lt;p&gt;There was a guard for this. It just could not fire.&lt;/p&gt;

&lt;p&gt;The code tracks a counter whose whole job is to notice that the browser-session identity changed underneath an in-flight operation. It is compared at six call sites, across the three functions that own the epoch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2885, 2896, 2920   _ensureBrowserSessionEpoch
3043               _hydrateOwnedTabs
3075, 3095         _persistOwnedTabs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each comparison guards a real invariant, and each one has a hand-written error message explaining what went wrong: &lt;em&gt;"Browser-session identity changed during initialization."&lt;/em&gt; &lt;em&gt;"Browser session changed during tab ownership recovery."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The variable is assigned exactly once, on line 2819:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_browserEpochGeneration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the declaration. Nothing increments it. Ever.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'_browserEpochGeneration\s*(\+\+|--|=[^=])'&lt;/span&gt; extension/background.js
2819:let _browserEpochGeneration &lt;span class="o"&gt;=&lt;/span&gt; 0&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hit, and it is the &lt;code&gt;let&lt;/code&gt;. Six comparisons against a constant, plus three sites that dutifully capture it into a local first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it survived review, including mine
&lt;/h2&gt;

&lt;p&gt;I have been staring at this file for months, and I want to be precise about why I did not see it, because "you should have looked harder" is not a lesson anyone can apply.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The guards read as protection.&lt;/strong&gt; Every site looks like careful concurrency code. There is a captured local, an await, and a re-check afterwards — the exact shape you are trained to pattern-match as &lt;em&gt;correct&lt;/em&gt;. Reading for shape rather than for reachability, it passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three of the six had a live half.&lt;/strong&gt; They are compound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;_browserEpochGeneration&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;browserEpoch&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;_browserSessionEpoch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second clause works. So those three guards do fire, sometimes, on the epoch comparison alone — which is exactly why this bug shows up at rehydration time rather than never at all. Half of the protection was real, and the working half masked the dead half. If all six had been dead, the failure would have been louder and I would have found it sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other three, the fully dead ones, were the ones that mattered most.&lt;/strong&gt; They sit inside &lt;code&gt;_ensureBrowserSessionEpoch&lt;/code&gt; and have no second clause, because that function is the thing that &lt;em&gt;sets&lt;/em&gt; the value they would compare against. Those three were supposed to catch the rotation &lt;strong&gt;at mint time&lt;/strong&gt;, before a doomed token is ever handed to a caller. Instead the rotation is caught at rehydration, long after the bad tokens are in the client's hands and in durable storage.&lt;/p&gt;

&lt;p&gt;A dead guard is worse than a missing one. A missing guard is an obvious gap. A dead guard is a gap plus a comment asserting the gap is covered, and it makes the next person to read the file — me, repeatedly — stop looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What would have caught it
&lt;/h2&gt;

&lt;p&gt;Not a unit test. Every behavioural test over these paths passes, and would still pass with all six comparisons deleted, because the code under test never rotates the epoch. The defect is an &lt;strong&gt;omission&lt;/strong&gt;, and you cannot observe an omission by exercising what is there.&lt;/p&gt;

&lt;p&gt;This project already had one test for exactly that category, written after a different omission bug, and its own comment says so:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is a source-level check on purpose: the failure is &lt;em&gt;omission&lt;/em&gt; at a call site, which no behavioural test over the existing sites can see.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That test reads the source as text and asserts a property about the call sites. The same trick works here, and the rule is embarrassingly simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A variable that is compared must be assigned somewhere other than its declaration.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a grep. It is cheap enough to run over every mutable module-level variable in a file, and it would have failed loudly the day this landed. Not a linter rule I had to invent either — "assigned once, read many, never reassigned" is the shape, and the moment you write it down you realise how many state machines and generation counters and dirty-flags could rot this way without a single test turning red.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I have not done
&lt;/h2&gt;

&lt;p&gt;I have not shipped the fix.&lt;/p&gt;

&lt;p&gt;The change itself is small: stop trusting the cached epoch, re-verify it against storage, and increment the counter when the two disagree — which lights up all six existing guards at once. I know what to type.&lt;/p&gt;

&lt;p&gt;But this is the tab-ownership guard, and the reason it exists in the first place is that early versions of this server genuinely clicked into and closed people's tabs. The failure mode of getting the fix wrong is inverted from the current bug: today a good token is rejected rarely, in a narrow restart window. An over-eager rotation check would reject good tokens &lt;em&gt;routinely&lt;/em&gt;. That is a strictly worse trade, and I am not making it against a theory. I want a staged reproduction that captures the epoch from both storage and the persisted record at mint time, and shows them differing, before I touch the file.&lt;/p&gt;

&lt;p&gt;So for now the bug is open, documented, and worse than the fix — on purpose. The guard is dead either way; at least now it is dead and labelled.&lt;/p&gt;




&lt;p&gt;If you want to look: the server is &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt;, and the full analysis lives on &lt;a href="https://github.com/achiya-automation/safari-mcp/issues/105" rel="noopener noreferrer"&gt;issue #105&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The question I am genuinely stuck on:&lt;/strong&gt; how do you catch a dead guard in your own code? Grepping for compared-but-never-assigned finds this exact shape, but it is one instance of a bigger family — the &lt;code&gt;if&lt;/code&gt; that can never be true, the &lt;code&gt;catch&lt;/code&gt; that can never be reached, the flag nobody flips. Has anyone found a check for that family that is cheap enough to run on every commit and quiet enough that people do not start ignoring it?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>debugging</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>I Rotated 4 Secrets in 6 Days. 3 of Them Broke Production.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 07 Sep 2026 05:39:55 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-rotated-4-secrets-in-6-days-3-of-them-broke-production-3f0j</link>
      <guid>https://dev.to/achiya-automation/i-rotated-4-secrets-in-6-days-3-of-them-broke-production-3f0j</guid>
      <description>&lt;p&gt;Every guide on secret rotation ends with "and then update the consumers." That sentence is where production dies.&lt;/p&gt;

&lt;p&gt;I run two self-hosted n8n instances for an automation studio in Israel. This morning I counted what they hold: &lt;strong&gt;114 credentials&lt;/strong&gt;, referenced from &lt;strong&gt;1,311 nodes&lt;/strong&gt; across 260 workflows. The single most-used credential (a Postgres connection) sits in &lt;strong&gt;233 nodes in 80 workflows&lt;/strong&gt;, 40 of them active. The WhatsApp gateway key is in 106 nodes.&lt;/p&gt;

&lt;p&gt;Between August 20 and August 25 I rotated four secrets. Three of them took something down. Each one failed in a different way, and none of the three failures was inside n8n. That is the part worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: the copy you forgot (2.5 days, zero alerts)
&lt;/h2&gt;

&lt;p&gt;August 20. I rotated a Chatwoot API token. The token is used by a WAHA→Chatwoot bridge, and the bridge keeps its own copy of the token &lt;strong&gt;per inbox app&lt;/strong&gt;. Six apps, six copies. I updated four.&lt;/p&gt;

&lt;p&gt;The two inboxes on the old token went quiet. Inbound messages hit &lt;code&gt;POST /contacts/filter&lt;/code&gt;, got &lt;code&gt;401 Invalid Access Token&lt;/code&gt;, and vanished. Outbound looked fine from the agent's side (the row was created in Chatwoot), then stalled on the same 401. No alert fired anywhere, because nothing was "down": the process was up, the endpoint answered, the queue just filled with failed jobs.&lt;/p&gt;

&lt;p&gt;I noticed on August 23. Two and a half days.&lt;/p&gt;

&lt;p&gt;The side effect was worse than the outage. Looking up a contact needs the token; &lt;strong&gt;creating&lt;/strong&gt; one goes through a public endpoint that doesn't. So every inbound message during the outage created a fresh &lt;code&gt;contact_inboxes&lt;/code&gt; row. When I finally counted, the table held 18,489 duplicates (some older, most from this), and those later produced 404s on &lt;code&gt;update_last_seen&lt;/code&gt;, because the bridge picked one mapping and the conversation sat on another.&lt;/p&gt;

&lt;p&gt;Fix that stuck: a daily check that validates every bridge app's token against Chatwoot, one line per app, non-zero exit if any fails. The hash comparison runs without printing a value: &lt;code&gt;sha256(config.accountToken)&lt;/code&gt; against &lt;code&gt;left(encode(sha256(token::bytea),'hex'),16)&lt;/code&gt; in &lt;code&gt;access_tokens&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 2: the derived value (14 hours of rejected calls)
&lt;/h2&gt;

&lt;p&gt;Same night, August 20, 22:55. I rotated the signing key of &lt;a href="https://onimli.com/" rel="noopener noreferrer"&gt;the Hebrew AI call-answering service I run&lt;/a&gt;. The key signs magic links and admin cookies, which I knew. It also derives the URL token that Telnyx uses to deliver inbound calls to &lt;code&gt;/voice/telnyx/&amp;lt;token&amp;gt;&lt;/code&gt;, which I had forgotten.&lt;/p&gt;

&lt;p&gt;Telnyx kept posting to the old URL. Every inbound call got &lt;code&gt;403 bad URL token&lt;/code&gt; until 13:07 the next day. Six calls from four numbers, rejected by a service whose only job is answering calls.&lt;/p&gt;

&lt;p&gt;Nothing in the rotation touched Telnyx, so nothing in it could have warned me. Three layers now: on every boot the service compares its webhook URL with what Telnyx has and corrects it; any 403 on the token triggers an immediate resync plus a Telegram alert; and a fallback URL at Telnyx points to an n8n workflow on a different server that plays an apology recording. Detection gap went from 14 hours to seconds. I verified that by firing a call at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: the shadow override (858 × 401 in 38 hours)
&lt;/h2&gt;

&lt;p&gt;August 23. WAHA API key. This one lives in six places: two lines in the &lt;code&gt;.env&lt;/code&gt;, an nginx snippet that injects the key on the public media path, two n8n credentials, and the local keychain. I had a tested procedure for all six.&lt;/p&gt;

&lt;p&gt;The procedure updated the nginx snippet. But the vhost file had a second, hardcoded &lt;code&gt;proxy_set_header X-Api-Key &amp;lt;old value&amp;gt;;&lt;/code&gt; line inside the &lt;code&gt;/api/files/&lt;/code&gt; location, left over from before the snippet existed. The hardcoded line won. The snippet variable was correct, and nothing read it.&lt;/p&gt;

&lt;p&gt;Result: 858 requests returned 401 over 38 hours, and every media message (voice notes, images, stickers) on five WhatsApp sessions stopped syncing to Chatwoot. In the inbox this shows up as "unsupported message type" notes, not as errors. The public vhost stayed green the whole time. Nothing on it fetches media except real clients.&lt;/p&gt;

&lt;p&gt;The verification I use now: from &lt;strong&gt;inside&lt;/strong&gt; the WAHA container, &lt;code&gt;curl&lt;/code&gt; a real media path &lt;strong&gt;through the public domain&lt;/strong&gt;. Localhost 200 plus public 401 means an injection layer is holding an old value.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that worked (4 destinations, verified in order)
&lt;/h2&gt;

&lt;p&gt;August 25. A client website's webhook secret, after it leaked: the webhook trigger node stores the full request headers in execution data, &lt;code&gt;X-Webhook-Secret&lt;/code&gt; included, and I had opened that execution to debug something else. Four destinations: a config row in Supabase, a file on the client's WordPress host, an n8n credential, and the keychain.&lt;/p&gt;

&lt;p&gt;What was different: I wrote the verification before the rotation. New secret → the endpoint returns 200. Old secret → 401. Then wait for the two scheduled workflows that consume it (a 2-minute and a 5-minute cron) to log &lt;code&gt;success&lt;/code&gt;. Only then delete the old value.&lt;/p&gt;

&lt;p&gt;It took longer than the other three. It was also the only one where I didn't have to explain anything to a client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Three failures, three different mechanisms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Where the stale copy lived&lt;/th&gt;
&lt;th&gt;What was silent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chatwoot token&lt;/td&gt;
&lt;td&gt;2 of 6 bridge apps&lt;/td&gt;
&lt;td&gt;Inbound messages dropped, no alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signing key&lt;/td&gt;
&lt;td&gt;A value &lt;strong&gt;derived&lt;/strong&gt; from it, registered at a third party&lt;/td&gt;
&lt;td&gt;Inbound calls rejected at the carrier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAHA key&lt;/td&gt;
&lt;td&gt;A hardcoded override under the variable I updated&lt;/td&gt;
&lt;td&gt;Media sync, while health stayed green&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rotation itself was never the problem. The &lt;strong&gt;inventory&lt;/strong&gt; was. None of my consumer lists was complete, and two were wrong in ways that only showed up after the old value stopped working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I run before touching a credential now
&lt;/h2&gt;

&lt;p&gt;The n8n side of the inventory is a query. Workflows store nodes as JSON, and each node with a credential carries &lt;code&gt;credentials: { &amp;lt;type&amp;gt;: { id, name } }&lt;/code&gt;. This flattens that into a blast-radius table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;wid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"isArchived"&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;workflow_entity&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;wid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;ctype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cid&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'credentials'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'credentials'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;deleted&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ctype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;wid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;workflows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;wid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;arch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;active_workflows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;credentials_entity&lt;/span&gt; &lt;span class="n"&gt;ce&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Names and counts only. It never touches the &lt;code&gt;data&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;Three things it told me this morning that I didn't know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;8 credential IDs are referenced by workflows but no longer exist&lt;/strong&gt; in &lt;code&gt;credentials_entity&lt;/code&gt;. The &lt;code&gt;&amp;lt;deleted&amp;gt;&lt;/code&gt; rows. All in inactive or archived workflows, but one of them is a Telegram credential still referenced from 43 nodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;44 of the 100 credentials on the main instance were modified in the last 30 days.&lt;/strong&gt; "We rotate rarely, so let's do it carefully" was the wrong mental model. We rotate constantly. It has to be routine.&lt;/li&gt;
&lt;li&gt;The Postgres credential with 233 references is a single point of failure that no dashboard shows. If I ever rotate that password, the order of operations matters more than the password.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the update itself: on n8n 2.36 the public API accepts &lt;code&gt;PATCH /api/v1/credentials/{id}&lt;/code&gt; with a &lt;code&gt;data&lt;/code&gt; object. I checked the route this morning with a body it had to ignore and got a 200 back. Ten days ago I was still creating a new credential, rewriting every node reference, and deleting the old one: three writes and a chance to miss a node. One PATCH, then confirm &lt;code&gt;versionId&lt;/code&gt; equals &lt;code&gt;activeVersionId&lt;/code&gt; on each consuming workflow, because a saved workflow is not necessarily the running one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The secret you already leaked
&lt;/h2&gt;

&lt;p&gt;One more thing from that fortnight, because it changed how I build &lt;a href="https://achiya-automation.com/services/ai-agents/" rel="noopener noreferrer"&gt;the AI agents I build for small businesses&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An insurance agency's reply bot pulled its API key from a &lt;code&gt;config&lt;/code&gt; table with a Postgres node: &lt;code&gt;SELECT value FROM config WHERE key = 'api_key'&lt;/code&gt;. Clean, no hardcoding.&lt;/p&gt;

&lt;p&gt;It sat in every execution instead. The node's output JSON contained the value, and n8n saves node output to &lt;code&gt;execution_data&lt;/code&gt;. When I went looking: &lt;strong&gt;980 rows&lt;/strong&gt; held that key, and 649 held a second one (a Chatwoot token loaded the same way). Every time anyone opened an execution to debug it, the secret was on screen. That is how it leaked to me.&lt;/p&gt;

&lt;p&gt;The fix is to keep the secret out of node output entirely. &lt;code&gt;httpCustomAuth&lt;/code&gt; merges its body straight into the request (&lt;code&gt;requestOptions.body = { ...requestOptions.body, ...customAuth.body }&lt;/code&gt; in &lt;code&gt;HttpRequestV3&lt;/code&gt;), and &lt;code&gt;httpHeaderAuth&lt;/code&gt; does the same for a header. Drop the column from the SELECT, attach the credential to the HTTP node, and the value never enters JSON. Then scrub what is already stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;execution_data&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s1"&gt;'sec'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'***REDACTED***'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;position&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="s1"&gt;'sec'&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Run it from a script that reads the value from stdin, so it doesn't land in your shell history either.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;For your single most-referenced credential: how many copies of it exist &lt;strong&gt;outside&lt;/strong&gt; the system that owns it? Bridges, proxies, a file on a client's host, a URL registered at a third party, a derived token.&lt;/p&gt;

&lt;p&gt;If you have the number, I'd like to hear how you keep it current. If you don't have the number, that is the number.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>security</category>
      <category>devops</category>
      <category>discuss</category>
    </item>
    <item>
      <title>The commit that fixed my only security advisory failed its security audit. The audit never ran.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Sun, 06 Sep 2026 05:45:07 +0000</pubDate>
      <link>https://dev.to/achiya-automation/the-commit-that-fixed-my-only-security-advisory-failed-its-security-audit-the-audit-never-ran-32ca</link>
      <guid>https://dev.to/achiya-automation/the-commit-that-fixed-my-only-security-advisory-failed-its-security-audit-the-audit-never-ran-32ca</guid>
      <description>&lt;p&gt;On 4 September I cut two releases of &lt;a href="https://github.com/achiya-automation/safari-mcp" rel="noopener noreferrer"&gt;safari-mcp&lt;/a&gt; ten minutes apart. The second one exists because of a mistake in the first, and its CI run went red for a reason that had nothing to do with either.&lt;/p&gt;

&lt;p&gt;Here is the sequence, because the order is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Release one: shipped with the alerts open
&lt;/h2&gt;

&lt;p&gt;v2.18.0 was a real release: receipts that survive a reconnect, screenshot downscaling, a queue for parked extension workers. CI was green, 227 tests passed locally, the changelog was written. I tagged it.&lt;/p&gt;

&lt;p&gt;What I had not done was look at the Dependabot tab. There was one open PR there, bumping &lt;code&gt;qs&lt;/code&gt; from 6.15.3 to 6.16.0 to close two moderate advisories. Lockfile-only, CI green, sitting there for a day. I released &lt;em&gt;around&lt;/em&gt; it. So the fresh version that landed on npm carried two advisories that anyone running &lt;code&gt;npm install&lt;/code&gt; would see in the summary line.&lt;/p&gt;

&lt;p&gt;I noticed within minutes, merged the bump, waited for CI on &lt;code&gt;main&lt;/code&gt;, and cut v2.18.1 with a one-line &lt;code&gt;### Security&lt;/code&gt; entry. The release workflow ran, npm got the new tarball, the registry picked it up. Fine. Lesson written down: merge the green Dependabot PRs &lt;em&gt;before&lt;/em&gt; cutting, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Release two: red CI on the commit whose only job was security
&lt;/h2&gt;

&lt;p&gt;Then the CI run for that release commit failed.&lt;/p&gt;

&lt;p&gt;The workflow runs the test matrix on three Node versions. One of them failed at this step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Audit production dependencies (fail on high+)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm audit --audit-level=high --omit=dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;npm warn audit 503 Service Unavailable - POST https://registry.npmjs.org/-/npm/v1/security/audits/quick
npm error audit endpoint returned an error
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;npm ci&lt;/code&gt; before it had taken seven minutes. npm was having a bad morning. The audit endpoint was unreachable, &lt;code&gt;npm audit&lt;/code&gt; exited non-zero, the step failed, and because the matrix has &lt;code&gt;fail-fast&lt;/code&gt; on by default, GitHub cancelled the other job that was still running. Final tally on the commit that fixed my only security advisory: one job passed, one failed, one cancelled. Red.&lt;/p&gt;

&lt;p&gt;The same thing happened again that afternoon on the next commit, on a different Node version. Two days later I re-ran both without changing a line. All green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gate actually measured
&lt;/h2&gt;

&lt;p&gt;I put &lt;code&gt;npm audit&lt;/code&gt; in CI so that a release cannot go out with a known high-severity vulnerability in production dependencies. That is a reasonable thing to want. But look at what the step can and cannot tell me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit 0:&lt;/strong&gt; the audit ran and found nothing at &lt;code&gt;high&lt;/code&gt; or above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit 1:&lt;/strong&gt; the audit ran and found something — &lt;em&gt;or&lt;/em&gt; the audit could not run at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two are not the same finding, and the step collapsed them into one red X. On 4 September the repository had zero open Dependabot alerts. There was nothing to find. The gate went red anyway, because it is a network call to a third party, and the third party was down.&lt;/p&gt;

&lt;p&gt;A check that fails when it cannot run is not a gate. It is a coin flip weighted by someone else's uptime. And the weighting is not small: &lt;code&gt;npm ci&lt;/code&gt; plus &lt;code&gt;npm audit&lt;/code&gt; are two round trips to the same registry, in the same job, with no retry between them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fail-fast&lt;/code&gt; then made it worse in the specific way that amplifiers do. One transient error on one Node version became three red jobs, two of which had no opinion about anything. The whole point of a matrix is to learn something &lt;em&gt;per version&lt;/em&gt;. Cancelling the siblings on the first failure throws that information away exactly when it would have been useful, because the one thing a green sibling would have told me is "this is not your code".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing was stopped
&lt;/h2&gt;

&lt;p&gt;Here is the part I am least comfortable with. The red CI did not block the release. The release workflow triggers on the tag, not on CI status, and I had cut the tag after checking that the &lt;em&gt;previous&lt;/em&gt; commit was green. So v2.18.1 was on npm before its own CI had finished, and the CI then went red on a commit that was already published.&lt;/p&gt;

&lt;p&gt;I had thought of CI as a gate. It was a lagging indicator. A gate that runs after the door has closed is documentation, and documentation that is wrong is worse than none — for two days the commit history said "this release failed CI", and it had not failed anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two changes, both small, both shipped this morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so each Node version reports its own result, and the audit step now tells the two failure modes apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail
&lt;span class="k"&gt;for &lt;/span&gt;attempt &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;npm audit &lt;span class="nt"&gt;--audit-level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;high &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tee &lt;/span&gt;audit.log&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"audit endpoint returned an error"&lt;/span&gt; audit.log &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"npm audit endpoint unavailable (attempt &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;/3) — retrying in 30s"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real finding still fails immediately. An endpoint error retries three times, then fails — which is correct, because after ninety seconds of no answer I genuinely do not know whether the release is clean, and "I don't know" should be red. What I have removed is the case where a single 503 pretends to be a vulnerability.&lt;/p&gt;

&lt;p&gt;The thing I have &lt;em&gt;not&lt;/em&gt; fixed is the ordering problem: the release still does not wait for CI on its own commit. That is a bigger change to a workflow that has finally been reliable for two weeks, and I am not going to touch it on the same morning I am writing about touching things too fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;The two mistakes on 4 September are opposites. The first was a gate I forgot to look at (Dependabot). The second was a gate that looked at nothing and reported anyway (&lt;code&gt;npm audit&lt;/code&gt; during an outage). I over-trusted one and under-checked the other, and the release that came out of it was fine — which is the uncomfortable bit, because "it was fine" is how these habits survive.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;when a CI check depends on an external service, do you fail the build when that service is down, or skip the check and mark it?&lt;/strong&gt; I chose "retry, then fail", on the argument that an unknown security state should be red. I can see the case for a yellow.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>github</category>
      <category>opensource</category>
      <category>security</category>
    </item>
    <item>
      <title>I Measured What Self-Hosted Chatwoot Actually Uses. 348,703 Messages, 1.8 GB of RAM.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Wed, 02 Sep 2026 16:49:33 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-measured-what-self-hosted-chatwoot-actually-uses-348703-messages-18-gb-of-ram-4cp1</link>
      <guid>https://dev.to/achiya-automation/i-measured-what-self-hosted-chatwoot-actually-uses-348703-messages-18-gb-of-ram-4cp1</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I run a small automation shop, I set Chatwoot up for clients, and &lt;code&gt;ACHIYAVS&lt;/code&gt; is my affiliate code — if you use it on Chatwoot Cloud you pay 5% less and I earn a commission. Self-hosting, which is most of this post, earns me nothing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every "Chatwoot pricing" article copies the same four numbers off the pricing page. None of them say what the free option actually consumes, because none of them are running it.&lt;/p&gt;

&lt;p&gt;I am. So I measured mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The install
&lt;/h2&gt;

&lt;p&gt;One box. Not a cluster, not a managed database, no autoscaling group.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client accounts&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inboxes&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seats&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversations, all time&lt;/td&gt;
&lt;td&gt;10,321&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messages, all time&lt;/td&gt;
&lt;td&gt;348,703&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messages, last 30 days&lt;/td&gt;
&lt;td&gt;125,521&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collecting since&lt;/td&gt;
&lt;td&gt;25 Nov 2025&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What it uses
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt;, steady state, mid-afternoon on a working day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;chatwoot-rails-1       827 MiB
chatwoot-postgres-1    595 MiB
chatwoot-sidekiq-1     368 MiB
chatwoot-redis-1         7 MiB
                     ---------
                      1.80 GiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres is 997 MB on disk. The whole box sits at 20 GB of 38 GB used and 2.4 GB of 3.7 GB RAM — and that box hosts other things too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;348,703 messages across 20 inboxes fit inside about 1.8 GB of RAM.&lt;/strong&gt; A 4 GB VPS runs this without strain.&lt;/p&gt;

&lt;p&gt;The structural point matters more than the number: &lt;strong&gt;that footprint tracks message volume, not seat count.&lt;/strong&gt; Adding an agent to a self-hosted install costs nothing. On Cloud, every seat is another line on the bill, every month, for as long as you have it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line item nobody quotes
&lt;/h2&gt;

&lt;p&gt;I went looking for what my install would cost on Cloud, and found the thing that actually decides the tier — and it is not the price.&lt;/p&gt;

&lt;p&gt;Each Cloud tier caps how long your conversation history is kept. Thirty days on the free tier, six months on the cheapest paid one, longer above that.&lt;/p&gt;

&lt;p&gt;My history goes back to 25 November 2025. That is 282 days. On the entry paid tier, roughly three months of client conversations would already be gone — not archived, gone.&lt;/p&gt;

&lt;p&gt;A support inbox is a record. "You told me in March that this was included" is a question someone has to be able to answer. That constraint, not the sticker price, is what pushes most teams up a tier.&lt;/p&gt;

&lt;p&gt;I put the full tier-by-tier breakdown, with the retention caps and the crossover math at different team sizes, on my own site: &lt;a href="https://achiya-automation.com/en/blog/chatwoot-pricing/" rel="noopener noreferrer"&gt;what Chatwoot actually costs&lt;/a&gt;. It is the version I wish I had found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost that is not in anyone's table
&lt;/h2&gt;

&lt;p&gt;Self-hosting is not free. It costs the thing hardest to price: your attention, at inconvenient hours.&lt;/p&gt;

&lt;p&gt;In the months I have run this I have handled Postgres connection-pool exhaustion, a Redis eviction policy that quietly dropped background jobs, a log rotation config that corrupted an access log badly enough to break my own monitoring, and an upgrade that reverted files I had mounted into the container.&lt;/p&gt;

&lt;p&gt;None of that was Chatwoot's fault. All of it was the price of the $0 licence.&lt;/p&gt;

&lt;p&gt;If your team does not already have someone comfortable in a terminal at 11pm, the honest answer is that Cloud is cheaper for you than self-hosting — because the alternative is not "a server," it is "a server plus a person."&lt;/p&gt;

&lt;h2&gt;
  
  
  So which one
&lt;/h2&gt;

&lt;p&gt;Self-host if you have ops capacity, more than a handful of seats, and you want history you control. The economics improve with every seat and never get worse.&lt;/p&gt;

&lt;p&gt;Take Cloud if support is not your engineering problem and you would rather pay for that boundary. That is a legitimate purchase, not a defeat.&lt;/p&gt;

&lt;p&gt;And if you take Cloud, the code is &lt;code&gt;ACHIYAVS&lt;/code&gt; for 5% off — which is the disclosure at the top of this post, and the real number. Search for a Chatwoot code and you will find "70% off" and "$100 off" on half a dozen coupon sites. The &lt;a href="https://www.chatwoot.com/affiliate-program" rel="noopener noreferrer"&gt;published affiliate terms&lt;/a&gt; give the customer 5%. Anything larger is expired or was never real.&lt;/p&gt;

&lt;p&gt;On 34 seats that discount is worth about $33 a month. It is not a reason to choose Cloud. It is just the correct number, which turns out to be surprisingly hard to find.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Figures measured on a production install, 2 September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you self-host Chatwoot — what does your install actually use, and did the retention cap ever catch you out?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>chatwoot</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Run n8n in Both Modes. Queue Mode's Peak Load: 19 Jobs.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 31 Aug 2026 05:42:13 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-run-n8n-in-both-modes-queue-modes-peak-load-19-jobs-4bo5</link>
      <guid>https://dev.to/achiya-automation/i-run-n8n-in-both-modes-queue-modes-peak-load-19-jobs-4bo5</guid>
      <description>&lt;p&gt;I run two production n8n instances. Both sit on identical 2 vCPU / 4 GB VPSes. One runs queue mode with a dedicated worker, Redis, and Postgres. The other runs plain single-process mode.&lt;/p&gt;

&lt;p&gt;Last week the queue-mode instance handled 76,100 executions. The regular one handled 322.&lt;/p&gt;

&lt;p&gt;Here's the part that should change how you think about queue mode: at the single busiest moment of those 76,100 runs, exactly &lt;strong&gt;19 executions were in flight at once&lt;/strong&gt;. My worker runs with &lt;code&gt;--concurrency=5&lt;/code&gt;. The box never broke a sweat.&lt;/p&gt;

&lt;p&gt;Queue mode is the most recommended "scaling" step in every n8n thread, and most people who follow that advice are buying RAM and operational complexity they will never use. I have the numbers, and two production incidents, to show where the line actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, queried this morning
&lt;/h2&gt;

&lt;p&gt;Everything below comes from live SQL against both instances' Postgres, run on the day I wrote this (2026-08-31), plus &lt;code&gt;docker stats&lt;/code&gt; snapshots.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Instance A (queue mode)&lt;/th&gt;
&lt;th&gt;Instance B (regular)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;n8n version&lt;/td&gt;
&lt;td&gt;2.36.7&lt;/td&gt;
&lt;td&gt;2.36.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;2 vCPU / 4 GB&lt;/td&gt;
&lt;td&gt;2 vCPU / 4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Executions, last 7 days&lt;/td&gt;
&lt;td&gt;76,100&lt;/td&gt;
&lt;td&gt;322&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active workflows&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Success rate&lt;/td&gt;
&lt;td&gt;99.65%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median run duration&lt;/td&gt;
&lt;td&gt;0.20 s&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 / p99 duration&lt;/td&gt;
&lt;td&gt;1.59 s / 8.02 s&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longest single run&lt;/td&gt;
&lt;td&gt;7,320 s (~2 h)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak concurrent executions&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;19&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM: n8n processes&lt;/td&gt;
&lt;td&gt;456 MiB main + 445 MiB worker&lt;/td&gt;
&lt;td&gt;507 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM: Redis&lt;/td&gt;
&lt;td&gt;4 MiB&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out.&lt;/p&gt;

&lt;p&gt;First, 88% of the retained executions on instance A finish in &lt;strong&gt;under one second&lt;/strong&gt;. This is what real automation traffic looks like: webhooks arrive, a filter node rejects most of them, done. It is not a compute workload. It's a plumbing workload.&lt;/p&gt;

&lt;p&gt;Second, the queue stack costs me roughly 900 MiB of n8n processes to do what the regular instance does in 507 MiB. Redis is a rounding error, but the worker is a whole second n8n. That's the price of admission, before you've gained anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What queue mode actually bought me
&lt;/h2&gt;

&lt;p&gt;Not speed. A sub-second execution is sub-second in either mode. The queue adds a Redis hop; nobody notices it, but nobody gains from it either.&lt;/p&gt;

&lt;p&gt;What it actually bought me, in order of how often it mattered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Crash isolation.&lt;/strong&gt; See that 7,320-second run in the table? That was an AI workflow hanging on a model call for two hours before dying with an error, and it wasn't alone: the three longest runs in my window are all AI workflows that hung and eventually errored. In regular mode, runs like that live inside the same process that serves your editor and your webhooks, and a memory-hungry one takes everything down with it. In queue mode each of them burned one worker slot out of five while everything else kept flowing. This is the real feature. It's an availability feature, not a performance feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Restart behavior.&lt;/strong&gt; I can restart the worker (after an update, after a leak) without dropping incoming webhooks. The main process keeps accepting; the queue holds jobs until the worker returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A scaling path I've never used.&lt;/strong&gt; If I ever need a second worker, it's one compose line. Peak of 19 in-flight against concurrency 5 says that day is far away. That in-flight count also includes parent workflows sitting idle waiting on sub-workflow calls, so the true CPU-busy number is lower still.&lt;/p&gt;

&lt;h2&gt;
  
  
  What queue mode charged me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The migration race.&lt;/strong&gt; Every version upgrade, &lt;code&gt;docker compose up -d&lt;/code&gt; starts main and worker together, and both immediately try to run database migrations on the same Postgres. n8n has no cross-instance migration lock. The main wins; the worker crashes mid-migration with a &lt;code&gt;MigrationExecutor.executePendingMigrations&lt;/code&gt; stack trace and sits there unhealthy. First time it happened (2.30.6 → 2.32.6, during a routine fleet upgrade), the worker sat unhealthy while I worked out whether my database was half-migrated.&lt;/p&gt;

&lt;p&gt;It wasn't. The fix is boring and permanent: wait for main to report healthy, then &lt;code&gt;docker restart&lt;/code&gt; the worker. It rejoins cleanly because the migrations are already done. But nobody tells you this before you switch, and an unhealthy worker after an upgrade looks exactly like a disaster until you know it's choreography.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second thing to monitor.&lt;/strong&gt; A dead worker in queue mode fails quietly: webhooks still return 200, jobs still enqueue, and nothing executes. In regular mode, when n8n is down, everything visibly fails, which is ugly but honest. Queue mode converts loud failures into silent backlogs. You need a health check on the worker specifically, not just on the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident that queue mode couldn't touch
&lt;/h2&gt;

&lt;p&gt;The one time this instance actually hurt, no execution mode would have saved it.&lt;/p&gt;

&lt;p&gt;One gateway workflow, a filter in front of a WhatsApp bot, was receiving every event on the line and rejecting most of them in its first node. 11,880 executions per day, 96% finishing under a second. Pure no-op traffic.&lt;/p&gt;

&lt;p&gt;The damage wasn't CPU. The box was idle. The damage was &lt;strong&gt;writes&lt;/strong&gt;: n8n persists every execution by default, and my pruning cap (&lt;code&gt;EXECUTIONS_DATA_PRUNE_MAX_COUNT=50000&lt;/code&gt;) quietly became the effective retention window. At that rate, 50K rows is 2.5 days. Sixteen of my active workflows had &lt;em&gt;zero&lt;/em&gt; saved executions left: a workflow failed three days earlier and there was no evidence it ever ran. Meanwhile &lt;code&gt;execution_data&lt;/code&gt; grew to 92% of a 1.6 GB database and the nightly dumps inflated the backup directory to 9.8 GB.&lt;/p&gt;

&lt;p&gt;The fix was two settings on one workflow: &lt;code&gt;saveDataSuccessExecution: none&lt;/code&gt;, &lt;code&gt;saveDataErrorExecution: all&lt;/code&gt;. You can see it in my daily counts: 18,238 saved executions on August 24, between 6,300 and 9,000 a day since. Same traffic, half the writes.&lt;/p&gt;

&lt;p&gt;Queue mode has no opinion about any of this. Workers don't reduce writes; they just move where the writing happens. If your n8n feels heavy, check what it's &lt;em&gt;storing&lt;/em&gt; before you scale what it's &lt;em&gt;computing&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the line actually is
&lt;/h2&gt;

&lt;p&gt;My rule after running both modes side by side:&lt;/p&gt;

&lt;p&gt;Stay in regular mode while &lt;strong&gt;all&lt;/strong&gt; of these are true: your runs are mostly short (check with the query below), a single stuck execution taking the UI down for a minute is survivable, and you're on one box anyway.&lt;/p&gt;

&lt;p&gt;Switch to queue mode when &lt;strong&gt;any&lt;/strong&gt; of these arrives: individual runs that go multi-minute or memory-heavy (my 2-hour hung AI call is the poster child), webhooks that must stay up while you restart things, or genuine horizontal scaling.&lt;/p&gt;

&lt;p&gt;Notice what's not on the list: raw execution count. 76,100 a week fits through &lt;code&gt;--concurrency=5&lt;/code&gt; because almost all of it is sub-second. Volume is the wrong trigger. Duration and blast radius are the right ones.&lt;/p&gt;

&lt;p&gt;Two queries to run against your own instance before you add containers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- What fraction of your runs are sub-second no-ops?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EPOCH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;under_1s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;execution_entity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Your true peak concurrency (sweep line over start/stop events)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;execution_entity&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;execution_entity&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"startedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="nv"&gt;"stoppedAt"&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the first query says most of your load is sub-second, fix your filters before your architecture. If the second says your peak is under 10, queue mode is an availability decision, not a capacity one — make it for the crash isolation or don't make it at all.&lt;/p&gt;

&lt;p&gt;These two instances run client-facing automation at &lt;a href="https://achiya-automation.com/en/services/business-automation/" rel="noopener noreferrer"&gt;the automation studio behind 69 production n8n workflows&lt;/a&gt;, so the "survivable downtime" bar is set by paying customers, not by my patience. That's why the busy instance got queue mode long before it needed a second worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's your peak?&lt;/strong&gt; Run the sweep query on your own instance and post the number. I am collecting evidence for a follow-up on how oversized most n8n deployments are. If you've crossed your worker concurrency in production, I especially want to hear what the workload was.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>devops</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Ran 89,479 WhatsApp Messages Through WAHA. Twilio: $604.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Thu, 27 Aug 2026 06:54:18 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-ran-89479-whatsapp-messages-through-waha-twilio-604-3bog</link>
      <guid>https://dev.to/achiya-automation/i-ran-89479-whatsapp-messages-through-waha-twilio-604-3bog</guid>
      <description>&lt;p&gt;Last month my WhatsApp stack moved 89,479 messages. I got no invoice for any of them.&lt;/p&gt;

&lt;p&gt;That is not a brag, it is the setup for an honest accounting. Because "self-hosting is cheaper" is the least interesting sentence in infrastructure, and it is usually said by someone who has never been paged at 7am by a bot that went quiet at 2am. I want to put a real number on both sides of that trade: the money Twilio would have charged, and the money self-hosting quietly takes back.&lt;/p&gt;

&lt;p&gt;All the numbers below were pulled or fetched on &lt;strong&gt;August 27, 2026&lt;/strong&gt;. The rate cards move quarterly, so check yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traffic, measured rather than estimated
&lt;/h2&gt;

&lt;p&gt;Five WhatsApp inboxes, bridged from &lt;a href="https://waha.devlike.pro/" rel="noopener noreferrer"&gt;WAHA&lt;/a&gt; into a self-hosted Chatwoot. Thirty days:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;messages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total&lt;/td&gt;
&lt;td&gt;89,479&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbound (from users)&lt;/td&gt;
&lt;td&gt;45,563&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outbound (from us)&lt;/td&gt;
&lt;td&gt;43,916&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most benchmarks stop here, multiply by a per-message rate, and publish. That answer is wrong, because &lt;strong&gt;Meta does not charge per message. It charges per template sent outside an open customer service window.&lt;/strong&gt; Multiplying my full 89,479 by a template rate overstates the Meta line by about 3x. Multiplying just the outbound half still overstates it by about 1.5x.&lt;/p&gt;

&lt;p&gt;Since November 1, 2024 non-template messages are free. Since July 1, 2025 utility templates &lt;em&gt;answering a user inside an open 24-hour window&lt;/em&gt; are also free. So the only line that costs money is the outbound message that goes out when nobody has written to you in the last day.&lt;/p&gt;

&lt;p&gt;Which means the number you actually need is not "how many messages," it is "how many outbound messages had no inbound message from that contact in the preceding 24 hours."&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that produces the real bill
&lt;/h2&gt;

&lt;p&gt;Here it is against Chatwoot's schema. It uses a window function rather than a correlated &lt;code&gt;NOT EXISTS&lt;/code&gt;, because on a messages table of any size the correlated version will happily eat your connection pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_type&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inbox_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;46&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- your WhatsApp inboxes&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'33 days'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;-- 0 = incoming, 1 = outgoing&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
      &lt;span class="k"&gt;ROWS&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;UNBOUNDED&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;PRECEDING&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;inside_window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
             &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_in&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;outside_window&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth stealing: pull &lt;strong&gt;33&lt;/strong&gt; days of history but count only the last &lt;strong&gt;30&lt;/strong&gt;, otherwise messages near the boundary look like they have no preceding inbound and you overstate your bill. And &lt;code&gt;ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING&lt;/code&gt; is what keeps a message from counting itself as its own window opener.&lt;/p&gt;

&lt;p&gt;My result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; outbound | inside_window | outside_window
----------+---------------+----------------
    43916 |         14314 |          29602
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So of 43,916 outbound messages, &lt;strong&gt;29,602 (67%) would have been billable templates.&lt;/strong&gt; The other third rode free inside an open conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that costs on Twilio
&lt;/h2&gt;

&lt;p&gt;Twilio's WhatsApp pricing is two layers. Its own fee is &lt;strong&gt;$0.005 per message, inbound or outbound&lt;/strong&gt;, and that one applies to all 89,479. On top sits Meta's template fee, passed straight through, which varies by the recipient's country calling code.&lt;/p&gt;

&lt;p&gt;I'm in Israel. Meta's USD rate card effective July 1, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Template category&lt;/th&gt;
&lt;th&gt;Israel&lt;/th&gt;
&lt;th&gt;UK&lt;/th&gt;
&lt;th&gt;Germany&lt;/th&gt;
&lt;th&gt;India&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Marketing&lt;/td&gt;
&lt;td&gt;$0.0353&lt;/td&gt;
&lt;td&gt;$0.0635&lt;/td&gt;
&lt;td&gt;$0.1365&lt;/td&gt;
&lt;td&gt;$0.0118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utility&lt;/td&gt;
&lt;td&gt;$0.0053&lt;/td&gt;
&lt;td&gt;$0.0220&lt;/td&gt;
&lt;td&gt;$0.0550&lt;/td&gt;
&lt;td&gt;$0.0014&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;$0.0053&lt;/td&gt;
&lt;td&gt;$0.0220&lt;/td&gt;
&lt;td&gt;$0.0550&lt;/td&gt;
&lt;td&gt;$0.0014&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Israel is a cheap market for utility and a mid market for marketing. Germany is over ten times Israel's utility rate. If you are benchmarking, your country's row is the whole story. A US-centric blog post about WhatsApp costs is nearly useless to you.&lt;/p&gt;

&lt;p&gt;The month, priced out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Twilio handling   89,479 × $0.005   = $447.40
Meta (utility)    29,602 × $0.0053  = $156.89
                                    ---------
                                      $604.29

Meta (marketing)  29,602 × $0.0353  = $1,044.95
                                    ---------
                                    $1,492.35
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;$604 if every billable template is a utility template. $1,492 if they are marketing.&lt;/strong&gt; The gap between those two numbers is not a pricing question, it is a template-categorization question, and Meta decides the category, not you.&lt;/p&gt;

&lt;p&gt;Scaled to the 10,000-messages-a-month shape people usually ask about, holding my ratios (49% outbound, 67% of that outside the window): &lt;strong&gt;$67.53 utility, $166.77 marketing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The other side of the ledger changed in June, quietly
&lt;/h2&gt;

&lt;p&gt;If you last looked at WAHA a year ago, you priced it wrong. WAHA used to sell a "Plus" license for the features anyone running it in production actually needs. In release &lt;strong&gt;2026.6.1&lt;/strong&gt;, announced June 21, 2026, every Plus feature moved into the free Core image and the tiers collapsed into a single &lt;strong&gt;optional&lt;/strong&gt; $5/month Community subscription. The project's own words: &lt;em&gt;"100% free and open source, with no limits on messages or time, and no license expiration."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So the software line is now genuinely $0.&lt;/p&gt;

&lt;p&gt;The hardware line: WAHA is running on a 2 vCPU / 4 GB / 40 GB x86 VPS. It is not alone on that box: Redis and a second internal service share it. While carrying those 89,479 messages, the machine's load average was &lt;strong&gt;0.05, 0.22, 0.23&lt;/strong&gt;. This workload is not compute-bound, it is session-bound. Whatever your provider charges for the cheapest 4 GB instance, single digits a month, it does not move the comparison. Round the infra to $10 and self-hosting is still about 60× cheaper than the utility-rate scenario.&lt;/p&gt;

&lt;p&gt;Which is exactly the point at which an honest post has to stop being a sales pitch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What self-hosting actually charges you
&lt;/h2&gt;

&lt;p&gt;It bills in incidents, not invoices. Here is last week's, in full.&lt;/p&gt;

&lt;p&gt;On August 23 I rotated &lt;code&gt;WAHA_API_KEY&lt;/code&gt;. The rotation updated &lt;code&gt;/opt/waha/.env&lt;/code&gt; and the nginx snippet that injects the header. What it did not update was a second vhost, an internal tailnet-only copy created weeks earlier by duplicating the public one, where the key had been written as a &lt;strong&gt;literal&lt;/strong&gt; instead of the &lt;code&gt;$waha_api_key&lt;/code&gt; variable.&lt;/p&gt;

&lt;p&gt;Result: every &lt;code&gt;GET /api/files/…&lt;/code&gt; over the internal path started returning 401. Bots that download an image, OCR it, and act on the contents stopped being able to fetch anything. Of the three code nodes involved, one threw and killed its run outright; the other two caught the error and skipped silently, so those pipelines produced output — just output with no images in it. The public vhost was untouched, so every check that went through the domain came back green.&lt;/p&gt;

&lt;p&gt;The failure surfaced the next morning, because nobody had sent a photo overnight. A one-line fix, roughly twelve hours of quiet wrongness, and a monitoring path that was structurally incapable of seeing it.&lt;/p&gt;

&lt;p&gt;Twilio would have absorbed that entire class of problem. That is what the $604 buys. Not messages. The absence of a category of 2am.&lt;/p&gt;

&lt;p&gt;And there is a risk that no amount of care removes. WAHA drives a real WhatsApp Web session. Its own homepage says it plainly: &lt;em&gt;"WhatsApp does not allow bots or unofficial clients on their platform, so this shouldn't be considered totally safe."&lt;/em&gt; A banned number is not a support ticket, it is a dead asset. That is a business risk, not a technical one, and it does not belong in a cost table. It belongs in whatever conversation you have before you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line I actually draw
&lt;/h2&gt;

&lt;p&gt;After enough of these deployments, the rule I use with clients is not about volume at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The number is the business.&lt;/strong&gt; A clinic's only phone line, a store's published contact. Official API, pay Twilio. The ban risk is unpriceable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal, operational, or a secondary channel.&lt;/strong&gt; Courier groups, staff notifications, an intake line that can be replaced in an afternoon. WAHA. The savings are real and the downside is survivable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing blasts at any volume.&lt;/strong&gt; Official API, and check the marketing rate for your country before you promise anyone a budget. At $0.0353 a message in Israel and $0.1365 in Germany, the same campaign has wildly different economics depending on who receives it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Volume is the wrong axis. Replaceability is the right one. This is roughly the split behind &lt;a href="https://achiya-automation.com/en/pricing/" rel="noopener noreferrer"&gt;the pricing I quote Israeli businesses for WhatsApp automation&lt;/a&gt;: the channel decision comes first, and the bill follows from it.&lt;/p&gt;

&lt;p&gt;One more thing worth knowing: Meta has further pricing changes landing &lt;strong&gt;October 1, 2026&lt;/strong&gt;, covering service and utility messages. Whatever you calculate today has a shelf life of about five weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the query
&lt;/h2&gt;

&lt;p&gt;The number that decides this for you is not 89,479 and it is not $604. It is your own &lt;code&gt;outside_window&lt;/code&gt; count, and it is the one input a vendor pricing page cannot give you.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;run it against your own inboxes and post the three numbers.&lt;/strong&gt; Outbound, inside window, outside window. I am specifically curious whether the 67% outside-window share holds anywhere else, or whether it is an artifact of how my bots are scheduled. If that ratio is stable across use cases, it is a much better rule of thumb than anything on a vendor pricing page.&lt;/p&gt;

</description>
      <category>whatsapp</category>
      <category>devops</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Audited 50,669 n8n Runs. 85% of Failures Alerted Nobody.</title>
      <dc:creator>אחיה כהן</dc:creator>
      <pubDate>Mon, 24 Aug 2026 05:40:54 +0000</pubDate>
      <link>https://dev.to/achiya-automation/i-audited-50669-n8n-runs-85-of-failures-alerted-nobody-47pm</link>
      <guid>https://dev.to/achiya-automation/i-audited-50669-n8n-runs-85-of-failures-alerted-nobody-47pm</guid>
      <description>&lt;p&gt;One of my own workflows — the gate that decides whether a new WhatsApp contact gets the bot or gets me — failed 24 times between Thursday and Sunday. Seventy-one hours. I found out on Monday morning, by opening the executions list for an unrelated reason.&lt;/p&gt;

&lt;p&gt;That is embarrassing for someone who sells automation. So instead of fixing the one workflow and moving on, I went and measured the actual state of error handling across all 225 workflows on my main production instance. Here is every execution my instance still retains — pruning is on, so this is a six-day window, 18–24 August:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Executions (6 days)&lt;/td&gt;
&lt;td&gt;50,669&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failed executions (&lt;code&gt;error&lt;/code&gt; + &lt;code&gt;crashed&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;106&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure rate&lt;/td&gt;
&lt;td&gt;0.21%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active workflows&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active workflows with an error workflow attached&lt;/td&gt;
&lt;td&gt;26 (43%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failures that occurred in a workflow with &lt;strong&gt;no&lt;/strong&gt; error handling&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90 of 106 (85%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;n8n 2.35.5, queue mode, Postgres. A 0.21% failure rate is fine. The 85% is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why silence is the default
&lt;/h2&gt;

&lt;p&gt;Error handling in n8n is opt-in, per workflow. You build one workflow whose trigger is the &lt;strong&gt;Error Trigger&lt;/strong&gt; node, then in every &lt;em&gt;other&lt;/em&gt; workflow you open Settings → Error Workflow and point it at that one. There is no switch that applies it to everything you have already built, or to everything you build next.&lt;/p&gt;

&lt;p&gt;That is a per-workflow checkbox on a list that grows every week. Nobody forgets on purpose. You just build workflow #61 at 11pm, it works, you activate it, and you move on. The failure mode of an opt-in safety net is that coverage decays quietly while the number in your head stays at "yeah, we have error handling."&lt;/p&gt;

&lt;p&gt;Mine had decayed to 43%.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the gap actually cost
&lt;/h2&gt;

&lt;p&gt;I pulled the failures grouped by workflow, with a column for whether that workflow had an error handler attached:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Guarded&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp group lead detection (AI)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;36.5 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal-line bot gate&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;70.9 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client error-handling workflow&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;1.2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campaign-failure watchdog&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;8.7 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website lead intake form&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;53.0 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMS dispatcher (outbox)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;1.2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appointment sync + no-show recovery&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1.1 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6 others&lt;/td&gt;
&lt;td&gt;mixed&lt;/td&gt;
&lt;td&gt;1–2 each&lt;/td&gt;
&lt;td&gt;~0 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last column, not the failure counts. Every window longer than nine hours belongs to an unguarded workflow. The longest is 70.9 hours — a WhatsApp bot gate that failed 24 times over three days while I was working on other things. Nothing was on fire. No customer wrote in. The bot simply did not answer new contacts, and the only reason I found it is that I went looking.&lt;/p&gt;

&lt;p&gt;The guarded workflows failed too — 16 times between them. Their longest window was 1.2 hours, because something told me.&lt;/p&gt;

&lt;p&gt;That is the whole value proposition of the Error Trigger, and it is not "fewer failures." It is &lt;strong&gt;failures that end in hours instead of days&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually scared me
&lt;/h2&gt;

&lt;p&gt;Three of my workflows contain an Error Trigger node. Those are the handlers — the things that send the Telegram alert. Here is how many active workflows depend on each one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Handler&lt;/th&gt;
&lt;th&gt;Workflows it guards&lt;/th&gt;
&lt;th&gt;Guarded itself?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client error handler&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generic error alert&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product owner alert&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every handler was unguarded. And the one covering 15 workflows is the same row from the table above: &lt;strong&gt;it failed 13 times inside that same six-day window.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For those 13 failures, 15 workflows had no alerting at all and no way to find out. The error handler cannot report its own errors, because the thing that reports errors is the error handler. Quis custodiet ipsos custodes, in YAML.&lt;/p&gt;

&lt;p&gt;You cannot fix this by pointing handler A at handler B, either. That just moves the single point of silence one hop and adds a cycle you will forget about. The catcher has to be watched from &lt;strong&gt;outside n8n&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern, in four parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. One handler, not one per project.&lt;/strong&gt; Fan-in beats fan-out. A single Error Trigger workflow that formats &lt;code&gt;{{ $json.workflow.name }}&lt;/code&gt;, &lt;code&gt;{{ $json.execution.id }}&lt;/code&gt; and &lt;code&gt;{{ $json.execution.error.message }}&lt;/code&gt; into one alert is easier to keep correct than six near-copies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Audit coverage with a query, not with your memory.&lt;/strong&gt; This is the whole audit — run it against your n8n Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'errorWorkflow'&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'errorWorkflow'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;guarded&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;workflow_entity&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;guarded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything with &lt;code&gt;guarded = f&lt;/code&gt; is a workflow that can fail into the void. If you are on n8n Cloud, the same check runs off &lt;code&gt;GET /api/v1/workflows?active=true&lt;/code&gt; and reading &lt;code&gt;settings.errorWorkflow&lt;/code&gt; per item.&lt;/p&gt;

&lt;p&gt;That query is now the first line of my weekly runbook, next to the backup check. A new &lt;code&gt;f&lt;/code&gt; row gets treated the way a failing test does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make the handler loud about workflows, not just about errors.&lt;/strong&gt; The first thing I changed in mine was putting the workflow name in the subject line instead of in the body. That sounds trivial. It is the difference between "an automation failed" (which you snooze) and "the lead intake failed" (which you do not).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Guard the guard from outside.&lt;/strong&gt; The Error Trigger has a blind spot that no amount of coverage fixes: it fires when an execution &lt;em&gt;fails&lt;/em&gt;. It does not fire when an execution never &lt;em&gt;starts&lt;/em&gt; — a deactivated trigger, a dead cron, a webhook whose URL changed, a worker that is not consuming the queue. A workflow that stopped running entirely produces zero failed executions and therefore zero alerts. It looks exactly like a quiet week.&lt;/p&gt;

&lt;p&gt;So the handler pings a dead man's switch on every run, and a plain cron on a different machine screams if the ping stops:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# in the error handler workflow, and in one heartbeat workflow per instance&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="s2"&gt;"https://your-monitor/ping/&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# elsewhere — not on the n8n box&lt;/span&gt;
&lt;span class="nv"&gt;LAST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m /var/lib/heartbeat/n8n1 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; LAST &lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 3600 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;notify &lt;span class="s2"&gt;"n8n1 heartbeat is &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; LAST&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;m stale"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; matters: a monitoring call that can fail the run it is monitoring is worse than no monitoring.&lt;/p&gt;

&lt;p&gt;That is it. Four parts, none clever. The reason it is worth writing down is that I had parts 1 and 3 for as long as I have run this instance and still ate a three-day outage, because I never had part 2 and never imagined I needed part 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I landed
&lt;/h2&gt;

&lt;p&gt;I am not going to pretend this is finished. As I write this, coverage is still 43% — what changed is that I now have the number, the query that produces it, and a list of 34 workflows in the order I am going to fix them, longest silent window first. The handler-watching-the-handler problem is the part I am fixing today, because it is one workflow and it covers fifteen.&lt;/p&gt;

&lt;p&gt;The honest reason I am writing this before finishing it: the audit took twenty minutes and the three-day outage took three days. Those twenty minutes are the cheapest thing in the entire stack I maintain — cheaper than the &lt;a href="https://achiya-automation.com/services/whatsapp-bot/" rel="noopener noreferrer"&gt;WhatsApp bots I run in production for clients&lt;/a&gt;, and a rounding error against the numbers in &lt;a href="https://achiya-automation.com/blog/business-automation-complete-guide/" rel="noopener noreferrer"&gt;this breakdown of what business automation actually costs&lt;/a&gt;. Run the query before you finish reading this post and you will probably learn something uncomfortable too.&lt;/p&gt;

&lt;p&gt;One genuine question, because I do not think I have solved this part: &lt;strong&gt;how do you monitor the trigger that never fires?&lt;/strong&gt; A heartbeat proves the instance is alive and proves &lt;em&gt;that&lt;/em&gt; workflow ran. It does not prove that a webhook from a third party still arrives, or that a specific cron survived the last upgrade. Per-workflow "expected minimum run rate" alerting is the obvious answer and it also sounds like a maintenance burden that decays exactly like error-workflow coverage did.&lt;/p&gt;

&lt;p&gt;If you run n8n in production: do you alert on absence of runs, and if so — how do you keep the thresholds from rotting?&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>devops</category>
      <category>automation</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
