<?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: Anguishe</title>
    <description>The latest articles on DEV Community by Anguishe (@bashsnippets).</description>
    <link>https://dev.to/bashsnippets</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%2F3909567%2F885dee1e-f72c-48d7-965f-91ee8ade012a.jpeg</url>
      <title>DEV Community: Anguishe</title>
      <link>https://dev.to/bashsnippets</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bashsnippets"/>
    <language>en</language>
    <item>
      <title>I Killed My Report Script Mid-Write. The Output File Never Noticed.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-killed-my-report-script-mid-write-the-output-file-never-noticed-52ff</link>
      <guid>https://dev.to/bashsnippets/i-killed-my-report-script-mid-write-the-output-file-never-noticed-52ff</guid>
      <description>&lt;p&gt;The failure this pattern prevents is a quiet one. A script writes its output straight to the path a consumer reads, dies halfway, and the consumer loads the torn file as though it were complete. Nobody sees an error, because the only process that failed is gone, and everything downstream of it exited 0 on partial data. Meanwhile &lt;code&gt;/tmp&lt;/code&gt; collects the working files of every run that never reached its cleanup line at the bottom.&lt;/p&gt;

&lt;p&gt;I did not want to trust the fix on paper, so I tested it the unfriendly way on my own machine, bash 5.3.9. A small generator writes a CSV, with a &lt;code&gt;sleep 30&lt;/code&gt; planted between its first and second row to stand in for a slow API. I started it, waited one second, and sent it SIGTERM from outside.&lt;/p&gt;

&lt;p&gt;This is the shape it runs in:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;FINAL_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?usage:&lt;span class="p"&gt; report.sh /path/to/output.csv&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;TMP_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;             &lt;span class="c"&gt;# unique path, created 0600&lt;/span&gt;
cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;                &lt;span class="c"&gt;# first line: capture before anything overwrites it&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;cleanup EXIT              &lt;span class="c"&gt;# registered on the line after mktemp&lt;/span&gt;

generate_rows &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;    &lt;span class="c"&gt;# every write goes to the temp path&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FINAL_PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# atomic on one filesystem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the kill left behind: exit status 143, which is 128 plus signal 15. The temp path &lt;code&gt;mktemp&lt;/code&gt; had handed out came back &lt;code&gt;No such file or directory&lt;/code&gt;, so the trap ran on the way down. And &lt;code&gt;out.csv&lt;/code&gt; still held the previous run's three complete lines. A consumer reading it at that moment gets yesterday's data, whole. Stale is something monitoring can see. Torn is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every way out is covered
&lt;/h2&gt;

&lt;p&gt;Walk the exits. The generator fails halfway: &lt;code&gt;set -e&lt;/code&gt; aborts, the &lt;code&gt;EXIT&lt;/code&gt; trap removes the partial temp file, the published file is untouched. A signal arrives — Ctrl-C, a shutdown's SIGTERM, my &lt;code&gt;kill&lt;/code&gt; — and the same trap runs, as the run above shows. The script succeeds: &lt;code&gt;mv&lt;/code&gt; has already moved the temp file, so &lt;code&gt;rm -f&lt;/code&gt; finds nothing, complains about nothing, and the status passes through.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EXIT&lt;/code&gt; is the reason this is four lines instead of twenty. It is a bash pseudo-signal that fires on normal completion, an explicit &lt;code&gt;exit 1&lt;/code&gt;, a &lt;code&gt;set -e&lt;/code&gt; abort, and after the handling of a real signal. Trap &lt;code&gt;INT&lt;/code&gt; or &lt;code&gt;TERM&lt;/code&gt; separately only when you want signal-specific behaviour, such as logging who killed you.&lt;/p&gt;

&lt;p&gt;Two scoping rules each save a confused hour. A trap set inside &lt;code&gt;$( )&lt;/code&gt; or a &lt;code&gt;( )&lt;/code&gt; group belongs to that subshell and fires when the subshell exits, so cleanup traps go at the top level. And a second &lt;code&gt;trap … EXIT&lt;/code&gt; replaces the first — traps do not stack — so when two resources need releasing, a temp file and a background &lt;code&gt;ssh -L&lt;/code&gt; tunnel for instance, both go in one handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ordering that launders a failure
&lt;/h2&gt;

&lt;p&gt;The comment on the &lt;code&gt;local code=$?&lt;/code&gt; line is the part people move. I ran four versions of the handler against a script that ends in &lt;code&gt;exit 3&lt;/code&gt;, and bash turned out more forgiving than the folklore:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A trap that removes the file and never calls &lt;code&gt;exit&lt;/code&gt;: the script exits &lt;strong&gt;3&lt;/strong&gt;. Bash keeps the original status.&lt;/li&gt;
&lt;li&gt;A trap ending in a bare &lt;code&gt;exit&lt;/code&gt;: &lt;strong&gt;3&lt;/strong&gt;. Inside an EXIT trap, a bare &lt;code&gt;exit&lt;/code&gt; reuses the status the script was leaving with.&lt;/li&gt;
&lt;li&gt;Capture first, then &lt;code&gt;rm -f&lt;/code&gt;, then &lt;code&gt;exit "$code"&lt;/code&gt;: &lt;strong&gt;3&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rm -f&lt;/code&gt; first, then &lt;code&gt;local code=$?&lt;/code&gt;, then &lt;code&gt;exit "$code"&lt;/code&gt;: &lt;strong&gt;0&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one version that reports success after a failure is the one that looks most careful. It captures &lt;code&gt;$?&lt;/code&gt; — after &lt;code&gt;rm&lt;/code&gt; has succeeded, so it captures rm's zero and exits with it. Cron sees success, alerting sees success, and the cleanup has removed the evidence too. Capture on the first line, clean second, exit last.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why mktemp and not /tmp/myscript.$$
&lt;/h2&gt;

&lt;p&gt;A predictable temp path is two bugs. Overlapping runs, retried jobs and helpers can collide on the same name and interleave their writes, which produces exactly the garbage this pattern is meant to prevent. And &lt;code&gt;/tmp&lt;/code&gt; is world-writable, so a guessable name invites something else on the box to plant a file or a symlink where you are about to write. &lt;code&gt;mktemp&lt;/code&gt; returns a unique path, created atomically, readable only by you. When a script needs several working files, make one directory with &lt;code&gt;mktemp -d&lt;/code&gt; and remove it whole in the same handler; every intermediate file inherits the cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  What trap cannot do, and why the design survives it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kill -9&lt;/code&gt; cannot be caught. No trap runs, and power loss is no different. That is not an argument against the trap; it is why the rest of the design looks the way it does. Orphans from an uncatchable death land in &lt;code&gt;/tmp&lt;/code&gt;, where reboot or tmpfiles ageing clears them, rather than in a data directory. And &lt;code&gt;mv&lt;/code&gt; means even an uncatchable death cannot publish a torn file, because being half-written and being at the published path are never true at the same moment.&lt;/p&gt;

&lt;p&gt;One cheap gate is worth adding wherever a consumer trusts your output: before the &lt;code&gt;mv&lt;/code&gt;, check the temp file's size with &lt;code&gt;wc -c&lt;/code&gt; against a floor, and exit non-zero instead of publishing an implausibly small result. An upstream that "succeeded" with an empty body then pages a human instead of reaching the dashboard.&lt;/p&gt;

&lt;p&gt;A cleanup path you have never executed is a cleanup path you are guessing about. Plant a sleep, kill it from another terminal, and check three things: &lt;code&gt;/tmp&lt;/code&gt; is clean, the output still holds the old complete file, and the exit code is not zero. It takes about a minute.&lt;/p&gt;




&lt;p&gt;The complete script, the FAQ on subshell scoping and signal lists, and the line-by-line breakdown: &lt;a href="https://bashsnippets.xyz/snippets/bash-trap-cleanup" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-trap-cleanup&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same survival kit continues with &lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling" rel="noopener noreferrer"&gt;bash error handling&lt;/a&gt;, so failures stop the script and the trap has something honest to report, &lt;a href="https://bashsnippets.xyz/snippets/bash-timeout-command" rel="noopener noreferrer"&gt;the timeout command&lt;/a&gt;, so a hang dies and cleans up instead of blocking forever, and the &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Cron Wrapper Generator&lt;/a&gt;, which assembles the lot around any command. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>set -euo pipefail Is Missing a Letter. My ERR Trap Stayed Silent Until I Added -E.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/set-euo-pipefail-is-missing-a-letter-my-err-trap-stayed-silent-until-i-added-e-326b</link>
      <guid>https://dev.to/bashsnippets/set-euo-pipefail-is-missing-a-letter-my-err-trap-stayed-silent-until-i-added-e-326b</guid>
      <description>&lt;p&gt;Here is a nine-line script that does everything the strict-mode articles tell you to. &lt;code&gt;set -euo pipefail&lt;/code&gt; on line 2. On line 3, an ERR trap whose entire job is to print the failing command and its line number: &lt;code&gt;trap 'echo "✗ failed: ${BASH_COMMAND} (line ${LINENO})" &amp;gt;&amp;amp;2' ERR&lt;/code&gt;. Then a helper function that runs &lt;code&gt;mkdir /proc/export&lt;/code&gt;, a directory that cannot be created, standing in for a full disk or a missing mount.&lt;/p&gt;

&lt;p&gt;I ran it on my machine, bash 5.3.9. It printed &lt;code&gt;start&lt;/code&gt;, then mkdir's own complaint, then exited 1. The trap said nothing. No failing command, no line number — the two facts it was registered to report. From a nightly job, that is the log you get: a start banner, one stderr line if you are lucky, and an exit code that tells you something broke but not what.&lt;/p&gt;

&lt;p&gt;Then I changed one character, &lt;code&gt;set -Eeuo pipefail&lt;/code&gt;, and ran it again. Same mkdir error, followed by &lt;code&gt;✗ failed: mkdir /proc/export (line 5)&lt;/code&gt;, and exit 1. The trap had been fine all along. Nobody had told it to follow the script into the function, and a function is where most real failures happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three flags, three promises, one missing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; gets pasted as a single incantation. It is three flags making three separate promises, and each has its own list of places where it quietly declines to keep them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-e&lt;/code&gt; says exit when a command fails and nobody is checking. Bash defines "checking" generously: the condition of an &lt;code&gt;if&lt;/code&gt;, anything left of &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; or &lt;code&gt;||&lt;/code&gt;, anything under &lt;code&gt;!&lt;/code&gt; — and, the part that surprises people, everything inside a function that was &lt;em&gt;called&lt;/em&gt; from one of those positions. A careful helper becomes unguarded the moment somebody wraps it in &lt;code&gt;if helper; then&lt;/code&gt;, and nothing warns you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-u&lt;/code&gt; says an unset variable is an error rather than an empty string — the difference between &lt;code&gt;rm -rf "$BUILD_DIR/"&lt;/code&gt; and &lt;code&gt;rm -rf /&lt;/code&gt; when the variable is spelled &lt;code&gt;BUILDDIR&lt;/code&gt; in one place.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-o pipefail&lt;/code&gt; says a pipeline fails if any stage fails, not only the last one. Without it, &lt;code&gt;curl … | jq …&lt;/code&gt; proceeds happily when curl dies and &lt;code&gt;jq&lt;/code&gt; parses the empty result into something harmless.&lt;/p&gt;

&lt;p&gt;None of those is the letter I was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the trap said nothing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;trap … ERR&lt;/code&gt; fires on the same conditions that make &lt;code&gt;-e&lt;/code&gt; exit, so it looks like the natural place to print a diagnostic. But by default &lt;strong&gt;the ERR trap is not inherited by functions, command substitutions, or subshells&lt;/strong&gt;. My failing &lt;code&gt;mkdir&lt;/code&gt; was one level down, inside a function. Errexit did its job and killed the script. The trap, registered in the top-level shell, never saw a failure that happened in the function's scope. Exit 1, and silence — which is precisely the log entry that tells you nothing at 03:00.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;-E&lt;/code&gt;, &lt;code&gt;errtrace&lt;/code&gt;, which makes the ERR trap follow you into functions and subshells. The handler I run now names the command, not only the line, because bash keeps the pieces in &lt;code&gt;BASH_COMMAND&lt;/code&gt; and &lt;code&gt;BASH_LINENO&lt;/code&gt;:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-Eeuo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;CROSS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✗"&lt;/span&gt;

on_err&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;     &lt;span class="c"&gt;# first line, before anything else overwrites it&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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; failed: '&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_COMMAND&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' (line &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_LINENO&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, exit &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;on_err ERR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that at the top, a failure anywhere in the script logs the command, the line and the exit status instead of nothing. &lt;code&gt;-E&lt;/code&gt; is the flag missing from nearly every strict-mode line on the internet. If you take one thing from this, take the E.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other silent spot: local
&lt;/h2&gt;

&lt;p&gt;The second silent spot is a line most of us have written dozens of times: &lt;code&gt;local out=$(some_command)&lt;/code&gt;. On the same box, a function running &lt;code&gt;local out=$(false)&lt;/code&gt; under &lt;code&gt;set -euo pipefail&lt;/code&gt; carried on to its next line and the script exited 0. That line never triggers errexit, and no trap fires for it, because &lt;code&gt;local&lt;/code&gt; is a command in its own right and its exit status — success, it declared the variable — is the one bash sees. The command substitution's failure is discarded before anyone looks. Declare on one line, assign on the next, and the failure is yours again. It is two lines instead of one everywhere you capture output into a local, and it is the bug behind ShellCheck's masked-return-value warning, which is not pedantry.&lt;/p&gt;

&lt;p&gt;One caveat so you do not chase a ghost: with &lt;code&gt;-E&lt;/code&gt; set, a failure inside an explicit &lt;code&gt;( subshell )&lt;/code&gt; fires the trap twice — once in the subshell, once in the parent as the non-zero status propagates. That is expected. If duplicate alerts matter, guard on a flag or move the work out of the subshell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup on every path, and when to leave it all off
&lt;/h2&gt;

&lt;p&gt;The ERR trap tells you what broke; the EXIT trap is what stops the script leaving a half-written file behind for the next stage to load as if it were complete. It runs on every termination, and the rule that makes or breaks it is the same one as above: capture &lt;code&gt;$?&lt;/code&gt; on the first line of the handler. Move &lt;code&gt;local code=$?&lt;/code&gt; below the &lt;code&gt;rm -f "$TMP_FILE"&lt;/code&gt; and it reports rm's status — zero, forever — and the script exits clean after a failure. That is how a script with strict mode at the top ends up reporting success: I ran that ordering against an &lt;code&gt;exit 3&lt;/code&gt; on bash 5.3.9 and the script exited 0.&lt;/p&gt;

&lt;p&gt;Strict mode is also not the right default everywhere. A health checker that runs twenty probes and expects some to fail will spend more &lt;code&gt;|| true&lt;/code&gt; than logic fighting &lt;code&gt;-e&lt;/code&gt;. &lt;code&gt;.bashrc&lt;/code&gt; should never set it, because one failed command would close your terminal. And &lt;code&gt;grep -q&lt;/code&gt; used as a test returns non-zero as data, not as an error. The guide covers where to leave it off, rather than pretending the flags are free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the log says now
&lt;/h2&gt;

&lt;p&gt;The template I start from now opens with &lt;code&gt;set -Eeuo pipefail&lt;/code&gt;, both traps registered before any work happens, &lt;code&gt;$?&lt;/code&gt; captured first in each handler, locals declared and assigned on separate lines. When it fails, the log says which line and which command. That is the whole difference between a script that exits 1 and a script that tells you why.&lt;/p&gt;

&lt;p&gt;The full template with every behaviour checked against bash 5.3 — where errexit goes silent, the &lt;code&gt;nounset&lt;/code&gt; defaults for variables that are only unbound in staging, and the assembled skeleton: &lt;a href="https://bashsnippets.xyz/guides/safe-bash-script-template" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/safe-bash-script-template&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you would rather not type it, the &lt;a href="https://bashsnippets.xyz/tools/bash-boilerplate-generator" rel="noopener noreferrer"&gt;Bash Boilerplate Generator&lt;/a&gt; emits this shape with your script name filled in, &lt;a href="https://bashsnippets.xyz/snippets/bash-trap-cleanup" rel="noopener noreferrer"&gt;trap cleanup on exit&lt;/a&gt; works the mktemp-and-atomic-mv half in full, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>scripting</category>
    </item>
    <item>
      <title>For Months My Dev Server Came Up on 3001. Tonight I Found Out Who Had 3000.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 16 Sep 2026 17:21:09 +0000</pubDate>
      <link>https://dev.to/bashsnippets/for-months-my-dev-server-came-up-on-3001-tonight-i-found-out-who-had-3000-54k1</link>
      <guid>https://dev.to/bashsnippets/for-months-my-dev-server-came-up-on-3001-tonight-i-found-out-who-had-3000-54k1</guid>
      <description>&lt;p&gt;A listener nobody remembered starting held port 3000 on my own machine since spring. One ss flag named the owner without root — and showed why 'list open ports' is three different questions with three different commands.&lt;/p&gt;

&lt;p&gt;Every time I started the dev server for this site, the terminal said &lt;code&gt;Port 3000 is in use, trying 3001 instead&lt;/code&gt;, and every time I read it and moved on. Next.js does not complain; it steps up one port and carries on, and so did I, for the better part of a season. There is a particular flavour of embarrassment in being the person who writes about port audits while a mystery listener sits on your own laptop that you have been silently working around since spring.&lt;/p&gt;

&lt;p&gt;Tonight I asked. &lt;code&gt;ss -ltn 'sport = :3000'&lt;/code&gt; came back with two rows — &lt;code&gt;0.0.0.0:3000&lt;/code&gt; and &lt;code&gt;[::]:3000&lt;/code&gt;, every interface, both address families — and a &lt;code&gt;Process&lt;/code&gt; column that was blank. Not because nothing was there. Because I was not root, and &lt;code&gt;ss -p&lt;/code&gt; only names sockets owned by your own user. Something on my machine was accepting connections from the whole network on 3000, and the tool I reached for shrugged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag that answers without sudo
&lt;/h2&gt;

&lt;p&gt;The snippet answer is &lt;code&gt;sudo ss -ltnp&lt;/code&gt;. It works. But I wanted to know what &lt;em&gt;thing&lt;/em&gt; this was more than I wanted a PID, and there is a flag that answers that for anyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-ltnpe&lt;/span&gt; &lt;span class="s1"&gt;'sport = :3000'&lt;/span&gt;
&lt;span class="c"&gt;# LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* ino:20816 sk:2002 cgroup:/system.slice/docker.service &amp;lt;-&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-e&lt;/code&gt; prints the socket's owning cgroup, and on a systemd box the cgroup path ends in the unit that created it. Port 3000 belonged to &lt;code&gt;docker.service&lt;/code&gt;. One &lt;code&gt;docker ps&lt;/code&gt; later: a &lt;code&gt;docker-proxy&lt;/code&gt; publishing an Open WebUI container's internal 8080 as host port 3000, started in the spring to try something, never stopped, exposed on every interface the machine has. No root needed to learn that. The kernel knows the cgroup of every socket, and &lt;code&gt;ss&lt;/code&gt; will tell whoever asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the question is three questions
&lt;/h2&gt;

&lt;p&gt;What I took from the evening is that "list open ports" is not one question, and most of the wrong answers online come from answering a different one than the person asked.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What&lt;/em&gt; is listening, and on which address, is &lt;code&gt;ss -ltun&lt;/code&gt; and needs no privilege. The address column matters more than the port: &lt;code&gt;127.0.0.1:9050&lt;/code&gt; is reachable from that machine and nowhere else whatever the firewall says, &lt;code&gt;0.0.0.0&lt;/code&gt; is the whole network, and &lt;code&gt;127.0.0.53%lo&lt;/code&gt; is systemd-resolved pinned to loopback. If you have been piping &lt;code&gt;ss&lt;/code&gt; through &lt;code&gt;grep&lt;/code&gt;, the built-in filter syntax — &lt;code&gt;ss -Hltn 'sport = :3000'&lt;/code&gt;, with &lt;code&gt;-H&lt;/code&gt; dropping the header so the output goes straight into &lt;code&gt;awk&lt;/code&gt; — is the thing to switch to.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Who&lt;/em&gt; owns it is the question that needs root for other users' PIDs, and the one where &lt;code&gt;-e&lt;/code&gt; gets you most of the way for free. &lt;code&gt;lsof -nP -iTCP -sTCP:LISTEN&lt;/code&gt; reads the same facts from the process side and resolves the user name, which &lt;code&gt;ss&lt;/code&gt; does not; &lt;code&gt;fuser 3000/tcp&lt;/code&gt; is the terse one, and it exits 1 in silence when you run it unprivileged against someone else's socket, which looks exactly like "nothing there."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Whether&lt;/em&gt; it is reachable is a different question again. A port can be listening and unreachable — bound to loopback, or behind a firewall rule — and reachable with nothing of yours listening, when something upstream forwards it. &lt;code&gt;nc -zv host 3000&lt;/code&gt; asks the socket directly; &lt;code&gt;Connection refused&lt;/code&gt; means the packet arrived and the kernel said no, while a timeout means a firewall ate it. On a box with no &lt;code&gt;nc&lt;/code&gt;, bash opens TCP sockets on its own through &lt;code&gt;/dev/tcp&lt;/code&gt;, and the &lt;code&gt;timeout 2&lt;/code&gt; in front of that is not optional, because a filtered port will hang the connect for two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What docker-proxy hides
&lt;/h2&gt;

&lt;p&gt;My listener was the general case, not a curiosity. When a container publishes a port, Docker starts a small userspace proxy that binds the host port and forwards into the container's network namespace. From the host, &lt;code&gt;ss&lt;/code&gt; sees the proxy. It never sees the application. So the host-side list answers "which host ports are exposed" and cannot answer "what is behind them" — that is &lt;code&gt;docker ps&lt;/code&gt; and its &lt;code&gt;PORTS&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;It cuts the other way too. A container port that is &lt;em&gt;not&lt;/em&gt; published does not appear in the host's &lt;code&gt;ss&lt;/code&gt; output at all; it lives in a different namespace. A host-level audit that ignores this will report a database as not listening while it serves an entire compose stack over an internal bridge. To see those, you enter the namespace with &lt;code&gt;nsenter&lt;/code&gt;, or run &lt;code&gt;ss&lt;/code&gt; inside the container if the image has it.&lt;/p&gt;

&lt;p&gt;And when there is no &lt;code&gt;ss&lt;/code&gt;, no &lt;code&gt;netstat&lt;/code&gt;, nothing at all — a scratch container — the kernel still publishes the socket table as text in &lt;code&gt;/proc/net/tcp&lt;/code&gt;, hex addresses and all. &lt;code&gt;0BB8&lt;/code&gt; is 3000. &lt;code&gt;0A&lt;/code&gt; is &lt;code&gt;LISTEN&lt;/code&gt;. One line of &lt;code&gt;awk&lt;/code&gt; reads it, and the &lt;code&gt;uid&lt;/code&gt; column is right there without root; the PID is the one thing that file cannot give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diff is the point
&lt;/h2&gt;

&lt;p&gt;Knowing the list tonight is worth less than knowing when it changes. The guide ends in a script that writes one CSV line per listening socket — protocol, address, port, service name, owner — using &lt;code&gt;-e&lt;/code&gt; so it degrades gracefully without root, and with &lt;code&gt;--diff&lt;/code&gt; compares against the previous run and exits 3 with a report naming what appeared and what vanished. While testing it I opened a Python &lt;code&gt;http.server&lt;/code&gt; on 8099 between runs; the third run named it. Had that been in root's crontab in April, the docker-proxy on 3000 would have been a one-line email the night it appeared, not a season of a dev server politely stepping aside.&lt;/p&gt;

&lt;p&gt;The full guide — every command run on this box with the output pasted as it came out, the &lt;code&gt;netstat&lt;/code&gt;-to-&lt;code&gt;ss&lt;/code&gt; translation table, the &lt;code&gt;/proc/net/tcp&lt;/code&gt; decoder, and the audit script: &lt;a href="https://bashsnippets.xyz/guides/open-ports-linux" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/open-ports-linux&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once you have the PID, &lt;a href="https://bashsnippets.xyz/snippets/kill-process-on-port" rel="noopener noreferrer"&gt;Kill a Process on a Port&lt;/a&gt; covers the SIGTERM-then-SIGKILL escalation without reaching for &lt;code&gt;-9&lt;/code&gt; first, the one-script version this guide grew out of is &lt;a href="https://bashsnippets.xyz/snippets/list-open-ports-linux" rel="noopener noreferrer"&gt;List All Open Ports on Linux&lt;/a&gt;, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>security</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Our Status Dashboard Was Green for 61 Hours While the API Was Down</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:01:09 +0000</pubDate>
      <link>https://dev.to/bashsnippets/our-status-dashboard-was-green-for-61-hours-while-the-api-was-down-3g4n</link>
      <guid>https://dev.to/bashsnippets/our-status-dashboard-was-green-for-61-hours-while-the-api-was-down-3g4n</guid>
      <description>&lt;p&gt;Our status dashboard scraped a partner's health API every two minutes and lit up green the entire weekend the partner was down. By Monday morning they'd been offline for 61 hours and our board had been reporting "all systems operational" the whole time. Nobody paged. Nothing logged an error. The graph was a flat, confident green line over a three-day outage.&lt;/p&gt;

&lt;p&gt;I wrote that scrape. I'd tested it against a live &lt;code&gt;200&lt;/code&gt;, watched it pull the status field, and shipped it, because the happy path worked and the happy path is the only path you ever see in a demo. Three separate bugs were sitting in the code the whole time, and every one of them would have passed a code review. The part I'm not proud of is that finding them took most of a Monday, and the fix for all three fit in about fifteen lines I'd skipped the first time.&lt;/p&gt;

&lt;p&gt;Calling an API from a shell script is three problems wearing one trenchcoat: making the request, reading the response, and knowing when either one failed. Get any of the three wrong and the failure is silent — which is the worst kind, because silent failures get discovered by your users, not your tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: curl exits 0 when the API returns a 500
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; reports on the transport, not the HTTP result. If it reached the server and got a complete response back — any response, including a &lt;code&gt;503&lt;/code&gt; maintenance page — it exits &lt;code&gt;0&lt;/code&gt;. That's why &lt;code&gt;set -euo pipefail&lt;/code&gt; doesn't save you here: from bash's point of view, nothing failed. The command ran, it got bytes back, everyone's happy.&lt;/p&gt;

&lt;p&gt;The scrape had been faithfully saving a &lt;code&gt;503&lt;/code&gt; HTML error page every two minutes and treating it as data. The fix is to make the status code something the script actually looks at:&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;response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 5 &lt;span class="nt"&gt;--max-time&lt;/span&gt; 30 &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;%{http_code}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;http_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="p"&gt;##*&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# last line is the status&lt;/span&gt;
&lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;response&lt;/span&gt;&lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="p"&gt;*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;         &lt;span class="c"&gt;# everything before it is the body&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;2xx&lt;/code&gt; is success, &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;5xx&lt;/code&gt; are transient and worth a retry, and any other &lt;code&gt;4xx&lt;/code&gt; is your own broken request and should fail loudly instead of being retried into oblivion. The two timeouts matter as much as the status check: a short &lt;code&gt;--connect-timeout&lt;/code&gt; so a dead host fails fast, and a hard &lt;code&gt;--max-time&lt;/code&gt; so a server that accepts your connection and then hangs can't leave a cron job wedged until the next run piles up behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: grep on JSON doesn't error, it lies
&lt;/h2&gt;

&lt;p&gt;Once you have a verified &lt;code&gt;2xx&lt;/code&gt; body, the next trap is reading fields out of it. Our script pulled the status with &lt;code&gt;grep '"status"' | cut&lt;/code&gt;, and on the &lt;code&gt;503&lt;/code&gt; error page &lt;code&gt;grep&lt;/code&gt; matched a &lt;em&gt;different&lt;/em&gt; &lt;code&gt;"status"&lt;/code&gt; string that happened to appear in the HTML and read it as &lt;code&gt;"ok"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;cut&lt;/code&gt;, and &lt;code&gt;sed&lt;/code&gt; are line-oriented, and JSON has no meaningful lines. The same object can be minified onto one line or pretty-printed across twenty and it's identical data — but every line-based pattern you write depends on one specific layout the API is free to change without telling you. When it changes, your pattern doesn't throw an error. It quietly matches the wrong bytes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;jq&lt;/code&gt; parses the document into a real structure and addresses it by path. Three flags carry most of the weight:&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;# -e sets the exit code from the result, so "missing" is a branch, not an empty string&lt;/span&gt;
&lt;span class="nv"&gt;healthy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-er&lt;/span&gt; &lt;span class="s1"&gt;'.healthy'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&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;"field missing"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# -r prints the raw value — without it "web-01" keeps its quotes and breaks comparisons&lt;/span&gt;
&lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.region.name'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# // supplies a real default, so a missing key becomes 0 instead of the literal null&lt;/span&gt;
&lt;span class="nv"&gt;stars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.stargazers_count // 0'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-r&lt;/code&gt;, &lt;code&gt;//&lt;/code&gt;, and &lt;code&gt;-e&lt;/code&gt; are the difference between a parse that survives an API reformat and one that breaks the next time someone on the other end runs a linter. Building &lt;code&gt;select()&lt;/code&gt; and projection filters by hand is fiddly and a wrong quote silently matches nothing, which is exactly what the &lt;a href="https://bashsnippets.xyz/tools/jq-filter-builder" rel="noopener noreferrer"&gt;jq Filter Builder&lt;/a&gt; is for — paste a real response, click the fields, and it evaluates the filter live against your JSON before you wire it into anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug three: the failure nobody was told about
&lt;/h2&gt;

&lt;p&gt;The third bug wasn't a bug in the code at all — it was the absence of one. The scrape could fail and nothing would say so. There was an email alert configured, technically; it went to an address forwarding to a distribution list everyone had muted in 2023. An alert nobody reads is decoration.&lt;/p&gt;

&lt;p&gt;A Slack incoming webhook puts the failure where the team is already looking. The mechanism is a single POST of a JSON payload to a secret URL, so there are exactly two ways to get it wrong — send malformed JSON, or leak the URL. Build the payload with &lt;code&gt;jq&lt;/code&gt; so an error message full of quotes and newlines can't break it, and wire it to a &lt;code&gt;trap&lt;/code&gt; so you never have to remember to call it:&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="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;:?set&lt;span class="p"&gt; it in the environment, never in the script&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

slack_alert&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;payload
  &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; text &lt;span class="s2"&gt;":rotating_light: &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'{text: $text}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'slack_alert "scrape failed at line $LINENO (exit $?)"'&lt;/span&gt; ERR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;set -e&lt;/code&gt;, any unhandled non-zero exit fires the trap and posts the failure — with the line number — before the script dies. You set the trap once at the top and the whole script is covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Any one of the three would have caught it on minute one
&lt;/h2&gt;

&lt;p&gt;That's the part worth sitting with. The &lt;code&gt;503&lt;/code&gt; would have tripped the status check. The reshaped error page would have tripped &lt;code&gt;jq -e&lt;/code&gt;. And the scrape falling over would have posted to Slack. Three independent guards, each closing one gap, and the outage needed all three to be missing to stay invisible for 61 hours. Wire the pattern together — request that fails on real failures, parse that doesn't lie, alert when either breaks — and you hear about a problem the first time it happens, not the third day.&lt;/p&gt;

&lt;p&gt;Full fetch → parse → alert walkthrough with the complete script: &lt;a href="https://bashsnippets.xyz/guides/shell-scripts-that-talk-to-apis" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/shell-scripts-that-talk-to-apis&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three pieces each have their own deep dive — &lt;a href="https://bashsnippets.xyz/snippets/bash-curl-api-requests" rel="noopener noreferrer"&gt;making the request safely with curl&lt;/a&gt;, &lt;a href="https://bashsnippets.xyz/snippets/bash-parse-json-jq" rel="noopener noreferrer"&gt;parsing the response with jq&lt;/a&gt;, and &lt;a href="https://bashsnippets.xyz/snippets/bash-slack-webhook-alerts" rel="noopener noreferrer"&gt;alerting to Slack on failure&lt;/a&gt; — and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>bash</category>
      <category>devops</category>
      <category>linux</category>
      <category>scripting</category>
    </item>
    <item>
      <title>Your Cron Job Works. That's Not the Same as Surviving Cron</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 08 Jul 2026 23:42:05 +0000</pubDate>
      <link>https://dev.to/bashsnippets/your-cron-job-works-thats-not-the-same-as-surviving-cron-1n84</link>
      <guid>https://dev.to/bashsnippets/your-cron-job-works-thats-not-the-same-as-surviving-cron-1n84</guid>
      <description>&lt;p&gt;There's a gap between a script that works when you run it and a script that survives running unattended for a year. The first only has to succeed once, with you watching, on a good day. The second has to keep working through slow disks, hung sockets, recovering services, and reboots — with nobody watching, because the entire point of cron is that nobody is watching.&lt;/p&gt;

&lt;p&gt;Almost every script I've seen take down a server was fine in a manual run and fell apart the first time conditions weren't ideal and there was no human there to notice. I've hit three versions of this personally: a sync that stacked copies until the box hit load 41, a backup that hung and didn't run for nine days without a peep, and a deploy that raced a database's boot and paged me at 11pm. Three commands, three failures, one underlying truth — "works on my machine" and "survives cron" are different engineering problems.&lt;/p&gt;

&lt;p&gt;Cron jobs die quietly in exactly three ways, and there's a fourth problem underneath all of them that keeps the first three invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invisible problem first: cron eats your output
&lt;/h2&gt;

&lt;p&gt;When cron runs a job, anything it prints to stdout or stderr goes nowhere unless you've configured otherwise. No log, no record, no trace. So a job can fail every night for a month and the only signal is the &lt;em&gt;absence&lt;/em&gt; of whatever it was supposed to produce — and you notice that when something downstream breaks, not when the job fails.&lt;/p&gt;

&lt;p&gt;Two things turn cron from silent to legible. &lt;code&gt;MAILTO&lt;/code&gt; at the top of the crontab mails you any output a job produces. More reliably, redirect every job's output to a log file you control:&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;*&lt;/span&gt;/5 &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; /usr/local/bin/safe-sync.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/sync.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; is the part people forget — it folds stderr into the same stream as stdout, so your errors land in the log instead of evaporating. Everything below assumes you've done this. A locked, time-bounded, retried job that still logs nothing is a job whose failures you'll discover by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overlap: lock it to one instance
&lt;/h2&gt;

&lt;p&gt;This is the failure that turns a transient slow patch into a self-inflicted outage. A sync scheduled every minute normally finishes in twenty seconds; one slow-disk afternoon it starts taking ninety, and cron launches a new copy every minute on top of the last, until a half-dozen copies are fighting over the same disk and the load climbs into the dozens.&lt;/p&gt;

&lt;p&gt;The guard is &lt;a href="https://bashsnippets.xyz/snippets/bash-flock-single-instance" rel="noopener noreferrer"&gt;&lt;code&gt;flock&lt;/code&gt;&lt;/a&gt;: a kernel-held lock on an open file descriptor that lets exactly one copy run and makes the rest skip. Unlike a PID file, there's nothing to clean up — the kernel releases the lock automatically when the process exits, crashes, or is killed, so you never inherit a stale lock from a run that died badly. But a lock has a sharp edge: if the locked job &lt;em&gt;hangs&lt;/em&gt;, it holds the lock forever and every future run skips. The job stops running entirely, silently. Which is the next problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hang: bound the runtime
&lt;/h2&gt;

&lt;p&gt;A hung command is worse than a failed one, because a failed command at least exits and frees its lock. A backup wedged on a held database lock, a &lt;code&gt;curl&lt;/code&gt; against a dead socket, an &lt;code&gt;ssh&lt;/code&gt; into a black hole — these never return. Under cron, "never returns" means the slot is jammed, the lock is held, and the job produces zero signal because it never gets far enough to log anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://bashsnippets.xyz/snippets/bash-timeout-command" rel="noopener noreferrer"&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;/a&gt; is the outside bound the command can't set on itself:&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;# SIGTERM at 5 minutes; if it's ignored (process stuck in I/O), SIGKILL 20s later&lt;/span&gt;
&lt;span class="nb"&gt;timeout&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; 20s 5m mysqldump &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; mydb &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backup/mydb.sql.partial
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits &lt;code&gt;124&lt;/code&gt; when it had to step in and &lt;code&gt;137&lt;/code&gt; when the command ignored the polite SIGTERM and had to be force-killed — codes worth branching on, because "slow" and "wedged" are different problems. The &lt;code&gt;-k&lt;/code&gt; grace matters specifically for processes stuck in uninterruptible I/O, which can't act on SIGTERM at all. Timeout is what makes a lock &lt;em&gt;safe&lt;/em&gt;: with both in place, a hang gets killed on a deadline, the lock always releases on time, and the failure becomes a loud &lt;code&gt;124&lt;/code&gt; instead of a silent gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transient failure: retry with backoff
&lt;/h2&gt;

&lt;p&gt;Not every failure deserves to kill the run. A fresh database container that isn't accepting connections for six seconds, a 503 during a rolling restart, a 429 explicitly asking you to back off — these are &lt;em&gt;transient&lt;/em&gt;. Treating the first one as fatal is how a normal six-second boot delay becomes a failed deploy at eleven at night.&lt;/p&gt;

&lt;p&gt;The guard is a bounded &lt;a href="https://bashsnippets.xyz/snippets/bash-retry-with-backoff" rel="noopener noreferrer"&gt;retry with exponential backoff&lt;/a&gt;: try the command, and on failure wait a delay that doubles each round, with jitter so parallel callers don't retry in lockstep, capped so a genuinely dead dependency fails fast instead of looping forever.&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;# Wait for the port to be ready before doing the work that depends on it&lt;/span&gt;
retry 6 nc &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; 2 db.internal 5432
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The discipline that makes retries safe rather than dangerous: retry transient failures only. A 404 fails identically on every attempt, so retrying it just delays the real error and buries it under retry noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composing all three — and knowing which job needs which
&lt;/h2&gt;

&lt;p&gt;Locking, bounding, and retrying are independent guards, and the real value is stacking them: a single run that can't overlap, can't hang, and rides out a blip — and tells you, by mail or log, when it finally does give up. That composition is mechanical enough to generate, which is exactly what the &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Hardened Cron Wrapper Generator&lt;/a&gt; does: paste the command, toggle the guards, get a ShellCheck-clean wrapper and the crontab line.&lt;/p&gt;

&lt;p&gt;But not every job needs all of it — wrapping a one-line &lt;code&gt;date&lt;/code&gt; echo in flock and timeout is ceremony. Add a lock to anything that mutates shared state and can outrun its interval. Add a timeout to anything that touches the network or a database. Add retries to anything that depends on something which boots or recovers on its own clock. Log everything, always. The full decision table — guard by guard, when to add it and when to skip it — is in the guide.&lt;/p&gt;

&lt;p&gt;Read the whole thing, including the "know when it broke" alerting layer and the decision matrix: &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three guards as standalone snippets: &lt;a href="https://bashsnippets.xyz/snippets/bash-flock-single-instance" rel="noopener noreferrer"&gt;flock&lt;/a&gt;, &lt;a href="https://bashsnippets.xyz/snippets/bash-timeout-command" rel="noopener noreferrer"&gt;timeout&lt;/a&gt;, &lt;a href="https://bashsnippets.xyz/snippets/bash-retry-with-backoff" rel="noopener noreferrer"&gt;retry with backoff&lt;/a&gt;. To write a new hardened script from a blank slate rather than wrap an existing command, the &lt;a href="https://bashsnippets.xyz/tools/bash-boilerplate-generator" rel="noopener noreferrer"&gt;Bash Boilerplate Generator&lt;/a&gt; and the &lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling" rel="noopener noreferrer"&gt;error-handling snippet&lt;/a&gt; cover the skeleton. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Three Lines of Bash Stand Between Your Cron Job and a Silent Outage</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Sun, 05 Jul 2026 17:32:17 +0000</pubDate>
      <link>https://dev.to/bashsnippets/three-lines-of-bash-stand-between-your-cron-job-and-a-silent-outage-1pjm</link>
      <guid>https://dev.to/bashsnippets/three-lines-of-bash-stand-between-your-cron-job-and-a-silent-outage-1pjm</guid>
      <description>&lt;p&gt;Three separate incidents taught me the same lesson over about a year. A &lt;code&gt;*/1&lt;/code&gt; rsync stacked six copies on a slow disk and drove a box to load 41. A &lt;code&gt;mysqldump&lt;/code&gt; hung on a database lock and didn't run for nine days without a single alert. A deploy raced a database's boot and paged me at 11pm for a migration that wasn't broken. Different commands, different failures — but the same root shape every time: a script that was fine when I ran it by hand fell over the first time conditions weren't ideal and nobody was watching.&lt;/p&gt;

&lt;p&gt;The fixes are individually small. A cron job that can't take down a server needs three guards: a lock so it can't overlap itself, a timeout so it can't hang forever, and a bounded retry so a transient blip doesn't kill the run. The problem isn't knowing that. It's that composing the three correctly by hand — getting the &lt;code&gt;flock&lt;/code&gt; file descriptor right, the &lt;code&gt;timeout&lt;/code&gt; SIGKILL grace right, the retry quoting right, and not mangling a pipeline in the process — is exactly the kind of fiddly that produces a wrapper that &lt;em&gt;looks&lt;/em&gt; right and silently isn't.&lt;/p&gt;

&lt;p&gt;So I built a generator that does the composition: &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;the Hardened Cron Wrapper Generator&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;p&gt;You paste in the command your cron job runs, toggle the guards you want, set the schedule, and it emits two things: a complete wrapper script and the crontab line that calls it. The generated bash follows a strict standard — &lt;code&gt;set -euo pipefail&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;/&lt;code&gt;CROSS&lt;/code&gt; defined before use, comments that explain &lt;em&gt;why&lt;/em&gt; each line is there — and it's ShellCheck-clean, which is the part that matters when you're pasting something into a root crontab.&lt;/p&gt;

&lt;p&gt;The toggles map exactly to the three failure modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lock (flock)&lt;/strong&gt; stops overlap. You pick the behavior: &lt;em&gt;Skip if busy&lt;/em&gt; (&lt;code&gt;-n&lt;/code&gt;) for frequent jobs where a missed run is harmless because the next one catches up, or &lt;em&gt;Wait then give up&lt;/em&gt; (&lt;code&gt;-w&lt;/code&gt;) for jobs that must eventually run but can tolerate a short queue. The tool puts the lock file under &lt;code&gt;/run/lock&lt;/code&gt; — a tmpfs cleared on reboot — not &lt;code&gt;/tmp&lt;/code&gt;, where temp-cleaners can delete a lock mid-run and let a second copy through. That single path choice is a bug people ship constantly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bound the runtime (timeout)&lt;/strong&gt; stops hangs. You set the max runtime and a SIGKILL grace period. The grace exists because &lt;code&gt;timeout&lt;/code&gt; first sends a polite SIGTERM, and a process wedged in uninterruptible I/O — a dead NFS mount, a held database lock — physically can't act on SIGTERM. The grace is how long to wait before sending SIGKILL, which the kernel enforces unconditionally. The tool sets a sane default; you tune it for jobs you know touch slow storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry transient failures&lt;/strong&gt; rides out the blip. You set max attempts and a base delay, and the backoff doubles each round up to a 30-second cap, with jitter so parallel callers don't retry in lockstep and re-overwhelm a recovering service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes the output trustworthy
&lt;/h2&gt;

&lt;p&gt;Two design decisions in the tool are worth calling out because they're the ones I'd get wrong by hand at 11pm.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;the crontab line changes shape depending on what you enable.&lt;/strong&gt; Lock and timeout are simple enough to inline directly on the crontab line — &lt;code&gt;flock&lt;/code&gt; and &lt;code&gt;timeout&lt;/code&gt; in front of the command, a one-liner. But retry and email-on-failure need a bash function and a &lt;code&gt;mailx&lt;/code&gt; call, which can't live on a crontab line. So when you turn either of those on, the output switches from an inline crontab one-liner to a full wrapper script plus a crontab line that calls it. The tool makes that decision for you instead of generating something that won't run.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;it's explicit about pipelines.&lt;/strong&gt; &lt;code&gt;timeout&lt;/code&gt; and the retry loop bound the single process they launch — so a raw &lt;code&gt;cmd1 | cmd2&lt;/code&gt; would only be partly guarded. The tool surfaces this: for a pipeline or multiple commands, you wrap them in &lt;code&gt;bash -c '...'&lt;/code&gt; yourself, and it tells you so, rather than silently generating a wrapper that bounds half your command.&lt;/p&gt;

&lt;p&gt;It also handles the thing underneath all three guards that people forget: logging. Cron throws stdout and stderr away by default, so a job can fail every night for a month and the only signal is the absence of whatever it was supposed to produce. The tool's timestamped-logging toggle redirects output to a file you control, and the email-alert toggle sends a &lt;code&gt;mailx&lt;/code&gt; failure notice after all retries are exhausted — so the job fails &lt;em&gt;loudly&lt;/em&gt; instead of disappearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generate it instead of memorizing it
&lt;/h2&gt;

&lt;p&gt;Because the failure mode of hand-writing these is that the wrapper looks correct and isn't. The &lt;code&gt;flock&lt;/code&gt; that used &lt;code&gt;/tmp&lt;/code&gt;. The &lt;code&gt;timeout&lt;/code&gt; with no &lt;code&gt;-k&lt;/code&gt; that left the wedged process alive. The retry loop that retried a 404 forever. Each of those is a one-character or one-decision mistake, and each one only shows up the night it matters. A generator that bakes the right defaults in — and explains them inline in the output so you can read what you're shipping — removes that whole class of error.&lt;/p&gt;

&lt;p&gt;Build your wrapper here: &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools/cron-wrapper-generator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The three snippets it composes, if you want the full reasoning behind each guard, are &lt;a href="https://bashsnippets.xyz/snippets/bash-flock-single-instance" rel="noopener noreferrer"&gt;flock&lt;/a&gt;, &lt;a href="https://bashsnippets.xyz/snippets/bash-timeout-command" rel="noopener noreferrer"&gt;timeout&lt;/a&gt;, and &lt;a href="https://bashsnippets.xyz/snippets/bash-retry-with-backoff" rel="noopener noreferrer"&gt;retry with backoff&lt;/a&gt;. Build the schedule itself in the &lt;a href="https://bashsnippets.xyz/tools/cron-job-builder" rel="noopener noreferrer"&gt;Cron Job Builder&lt;/a&gt;, and read the whole decision in &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron" rel="noopener noreferrer"&gt;Bash Scripts That Survive Cron&lt;/a&gt;. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>devops</category>
      <category>sysadmin</category>
      <category>linux</category>
    </item>
    <item>
      <title>I Spent 40 Minutes at 11pm Debugging a Deploy That Wasn't Broken</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 02 Jul 2026 03:21:37 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-spent-40-minutes-at-11pm-debugging-a-deploy-that-wasnt-broken-1p2</link>
      <guid>https://dev.to/bashsnippets/i-spent-40-minutes-at-11pm-debugging-a-deploy-that-wasnt-broken-1p2</guid>
      <description>&lt;p&gt;I once spent forty minutes at eleven at night debugging a deploy that wasn't broken. The release script ran the database migration, the migration threw &lt;code&gt;connection refused&lt;/code&gt;, the script exited non-zero, the deploy rolled itself back, and I got paged.&lt;/p&gt;

&lt;p&gt;So I did the things you do. I read the migration. I read the logs. I checked the database — it was up, it was healthy, it accepted my connection instantly. I re-ran the deploy and it worked. I chalked it up to gremlins and went to bed, which is the part I'm not proud of, because it happened again two days later. That time I watched the timing: the script brought up a fresh database container and started the migration about six seconds before Postgres finished initializing and began accepting connections. The migration was racing the database's boot. Most of the time it won. The times it lost, I lost forty minutes.&lt;/p&gt;

&lt;p&gt;The script wasn't wrong about anything except one assumption: that a dependency is ready the instant you ask for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  In production, dependencies are &lt;em&gt;eventually&lt;/em&gt; ready
&lt;/h2&gt;

&lt;p&gt;That's the mental model shift. Networks blip. A service you call returns a 503 for the two seconds it takes to finish a rolling restart. An API rate-limits you with a 429 it fully expects you to retry. A fresh container's database isn't accepting connections for its first few seconds. Treating the first failure as fatal turns every one of these normal, transient conditions into a paged engineer — and the script that handles them isn't smarter — it declines to give up on the first try.&lt;/p&gt;

&lt;p&gt;But retrying naively is its own trap. Retry instantly and you hammer a recovering service into staying down. Retry forever and a genuinely dead dependency hangs your script indefinitely. Retry a 404 and you wait a minute to confirm what you already knew. Good retries are bounded, backed off, and selective.&lt;/p&gt;

&lt;h2&gt;
  
  
  A retry function you can reuse anywhere
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Purpose: survive transient failures instead of dying on the first error&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CHECK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✓"&lt;/span&gt;
&lt;span class="nv"&gt;CROSS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✗"&lt;/span&gt;

&lt;span class="c"&gt;# retry &amp;lt;max_attempts&amp;gt; &amp;lt;command&amp;gt; [args...]&lt;/span&gt;
retry&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift
    local &lt;/span&gt;&lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1            &lt;span class="c"&gt;# base delay — doubles each round&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;max_delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30       &lt;span class="c"&gt;# cap so the backoff never runs away&lt;/span&gt;

    &lt;span class="k"&gt;until&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
        if&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt; attempt &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; max_attempts &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;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; '&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;' failed after &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt; attempts"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
            &lt;span class="k"&gt;return &lt;/span&gt;1
        &lt;span class="k"&gt;fi&lt;/span&gt;
        &lt;span class="c"&gt;# 0–2s of jitter so parallel callers don't all retry on the same beat&lt;/span&gt;
        &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;pause&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; delay &lt;span class="o"&gt;+&lt;/span&gt; RANDOM &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="k"&gt;))&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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; attempt &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt; failed — retrying in &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pause&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
        &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pause&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; attempt &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
        &lt;span class="nv"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; delay &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;((&lt;/span&gt; delay &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; max_delay &lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$max_delay&lt;/span&gt;
    &lt;span class="k"&gt;done

    &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;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; '&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;' succeeded on attempt &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# The actual fix for my 11pm deploy: wait for Postgres to accept connections.&lt;/span&gt;
retry 6 nc &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; 2 db.internal 5432
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; database reachable — running migration"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole engine is &lt;code&gt;until "$@"; do ... done&lt;/code&gt;. &lt;code&gt;until&lt;/code&gt; runs the command and executes the loop body only when it &lt;em&gt;fails&lt;/em&gt;, exiting the instant it succeeds. Passing the command as &lt;code&gt;"$@"&lt;/code&gt; (after &lt;code&gt;shift&lt;/code&gt;-ing past the attempt count) means the function retries &lt;em&gt;anything&lt;/em&gt; — a &lt;code&gt;curl&lt;/code&gt;, an &lt;code&gt;ssh&lt;/code&gt;, a port check, your own script — without caring what it is.&lt;/p&gt;

&lt;p&gt;The backoff is the three lines at the bottom of the loop: sleep for the current delay plus a little jitter, then double the delay, capped at &lt;code&gt;max_delay&lt;/code&gt;. That gives you 1s, 2s, 4s, 8s, 16s, 30s, 30s…&lt;/p&gt;

&lt;h2&gt;
  
  
  The jitter is not decoration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RANDOM % 3&lt;/code&gt; looks trivial, and it's the line people delete to "clean up." Don't. Without jitter, a fleet of machines that all failed at the same instant — because the same service went down — will all retry at the same instant, and the same instant after that, producing a synchronized thundering herd that knocks the recovering service straight back over on every round. A few hundred milliseconds of randomness per client spreads the retries out so the service actually gets room to recover. At one machine it does nothing; at fifty it's the difference between recovery and a retry storm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake that makes retries dangerous
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Good: a transient failure that retrying can fix&lt;/span&gt;
retry 6 nc &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; 2 db.internal 5432

&lt;span class="c"&gt;# Bad: retrying a deterministic failure just delays the error 30 seconds&lt;/span&gt;
retry 6 curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; https://api.example.com/v1/thing-that-returns-404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A retry loop is only as smart as what you point it at. A port check belongs in a loop because the answer &lt;em&gt;changes&lt;/em&gt; — "no" until the database boots, then "yes." A request that returns 404 returns 404 on attempt one and attempt six; the loop just postpones the failure and buries the real status under retry noise. Retry transient failures — timeouts, connection-refused, 429, 5xx, DNS hiccups. Don't retry deterministic ones — a 404, a 401, a syntax error, a missing file. When you can, branch on the exit code or HTTP status and loop only on the codes worth looping on.&lt;/p&gt;

&lt;p&gt;For plain &lt;code&gt;curl&lt;/code&gt;, its built-in &lt;code&gt;--retry 5 --retry-delay 2&lt;/code&gt; does most of this and is simpler; reach for the function when the thing you're retrying isn't curl, or when you want one backoff policy across a database probe, an ssh call, and a download at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to 11pm
&lt;/h2&gt;

&lt;p&gt;That deploy never paged me again once the migration &lt;em&gt;waited&lt;/em&gt; for the port instead of assuming it. The database still took its six seconds to boot, the network still blipped occasionally — retrying didn't make the dependencies faster. It stopped a normal, transient slowness from being treated as a fatal error, which is most of what "production-ready" means for a script.&lt;/p&gt;

&lt;p&gt;Full function with the wait-for-port pattern and the guidance on which failures to retry: &lt;a href="https://bashsnippets.xyz/snippets/bash-retry-with-backoff" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Retries are the third leg of an unattended job: &lt;a href="https://bashsnippets.xyz/snippets/bash-flock-single-instance" rel="noopener noreferrer"&gt;flock&lt;/a&gt; stops overlap, &lt;a href="https://bashsnippets.xyz/snippets/bash-timeout-command" rel="noopener noreferrer"&gt;timeout&lt;/a&gt; stops hangs, retry rides out the blip. The &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Hardened Cron Wrapper Generator&lt;/a&gt; wires all three into one wrapper, &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron" rel="noopener noreferrer"&gt;Bash Scripts That Survive Cron&lt;/a&gt; is the end-to-end version, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>devops</category>
      <category>linux</category>
      <category>cicd</category>
    </item>
    <item>
      <title>My Backup Hadn't Run in 9 Days and Nothing Told Me</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Fri, 26 Jun 2026 03:39:56 +0000</pubDate>
      <link>https://dev.to/bashsnippets/my-backup-hadnt-run-in-9-days-and-nothing-told-me-20hg</link>
      <guid>https://dev.to/bashsnippets/my-backup-hadnt-run-in-9-days-and-nothing-told-me-20hg</guid>
      <description>&lt;p&gt;The backup ran fine every night for fourteen months, and then it didn't run for nine days, and nothing told me. No error in the log. No failed-job alert. No bounced cron mail. The nightly &lt;code&gt;mysqldump&lt;/code&gt; had hung — the database had a long-held lock from a runaway analytics query, the dump opened its transaction and sat there waiting for it, forever.&lt;/p&gt;

&lt;p&gt;Cron launched it at 2am, it never exited, and here's the cruel part: because I'd been &lt;em&gt;smart&lt;/em&gt; enough to wrap it in a lock so two dumps couldn't run at once, every subsequent night's run saw the lock still held by the zombie from the 9th and skipped quietly. The clever lock turned a one-night hang into a nine-day outage. I found it when I went to restore a table and discovered my newest "backup" was a &lt;code&gt;mysqldump&lt;/code&gt; process that had been running since the previous Tuesday.&lt;/p&gt;

&lt;p&gt;That's nine days I'd have lost if anything had gone wrong with the live database. The ten minutes of feeling foolish when I traced it back was nothing next to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  A hung command is worse than a failed one
&lt;/h2&gt;

&lt;p&gt;This is the lesson worth internalizing, because it's counterintuitive. A &lt;em&gt;failed&lt;/em&gt; command exits, frees its lock, and the next run tries again — the system self-heals. A &lt;em&gt;hung&lt;/em&gt; command exits never. It holds resources, blocks its own future runs, and produces exactly zero signal because it never gets far enough to log anything. Failures are loud. Hangs are silent, and silence is what kills you in unattended automation.&lt;/p&gt;

&lt;p&gt;You can't rely on a command to bound its own runtime, either. The whole problem is that it's wedged somewhere it can't time itself out of — blocked in the kernel waiting on a lock, or on a dead socket that will never send the FIN it's waiting for. So you bound it from the outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounding the runtime with timeout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Script: bounded-dump.sh&lt;/span&gt;
&lt;span class="c"&gt;# Purpose: Stop a hung command from running forever and jamming the cron slot&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CHECK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✓"&lt;/span&gt;
&lt;span class="nv"&gt;CROSS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✗"&lt;/span&gt;

&lt;span class="nv"&gt;MAX_RUNTIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"5m"&lt;/span&gt;   &lt;span class="c"&gt;# longer than the normal worst case, well under the interval&lt;/span&gt;
&lt;span class="nv"&gt;KILL_GRACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"20s"&lt;/span&gt;   &lt;span class="c"&gt;# after SIGTERM, wait this long, then SIGKILL&lt;/span&gt;
&lt;span class="nv"&gt;DEST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/backup/mydb.sql"&lt;/span&gt;

&lt;span class="c"&gt;# In an `if` so set -e doesn't abort before we read the exit code.&lt;/span&gt;
&lt;span class="c"&gt;# Write to a .partial file so a timed-out run never leaves a corrupt "backup".&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;timeout&lt;/span&gt; &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KILL_GRACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAX_RUNTIME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        mysqldump &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; mydb &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;.partial"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;.partial"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;"&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;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; dump completed within &lt;/span&gt;&lt;span class="nv"&gt;$MAX_RUNTIME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST&lt;/span&gt;&lt;span class="s2"&gt;.partial"&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
        &lt;/span&gt;124&lt;span class="p"&gt;)&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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; dump exceeded &lt;/span&gt;&lt;span class="nv"&gt;$MAX_RUNTIME&lt;/span&gt;&lt;span class="s2"&gt; — terminated (SIGTERM)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2 &lt;span class="p"&gt;;;&lt;/span&gt;
        137&lt;span class="p"&gt;)&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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; dump ignored SIGTERM — force-killed (SIGKILL)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2 &lt;span class="p"&gt;;;&lt;/span&gt;
        &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; dump failed with exit code &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2 &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;esac&lt;/span&gt;
    &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timeout -k "$KILL_GRACE" "$MAX_RUNTIME" mysqldump ...&lt;/code&gt; is the entire mechanism. At five minutes, &lt;code&gt;timeout&lt;/code&gt; sends the dump a SIGTERM. A well-behaved program treats SIGTERM as "wrap up and exit." But the dump from my outage wasn't misbehaving — it was blocked in the kernel waiting on a lock, and a process in that state physically cannot act on SIGTERM. That's what &lt;code&gt;-k 20s&lt;/code&gt; is for: twenty seconds after the polite signal, &lt;code&gt;timeout&lt;/code&gt; sends SIGKILL, which the kernel enforces unconditionally. Nothing survives SIGKILL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the exit code — it's the difference between a log and a mystery
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;124  &lt;span class="c"&gt;# still running at the deadline — SIGTERM was sent&lt;/span&gt;
137  &lt;span class="c"&gt;# 128 + 9 — ignored SIGTERM, had to be force-killed&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;    &lt;span class="c"&gt;# anything else is the command's own failure&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Collapsing all three into "backup failed" throws away the one piece of information that tells you whether you have a slow database, a wedged one, or a broken dump command. A &lt;code&gt;124&lt;/code&gt; says "this is taking too long — investigate the query." A &lt;code&gt;137&lt;/code&gt; says "this is wedged in I/O — investigate the lock or the mount." They point at different problems. (If you ever blank on which code is which, &lt;a href="https://bashsnippets.xyz/tools/bash-exit-code-lookup" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools/bash-exit-code-lookup&lt;/a&gt; decodes 124 and 137 directly.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;.partial&lt;/code&gt; trick matters more than it looks
&lt;/h2&gt;

&lt;p&gt;If you redirect straight to the real backup file and the command times out mid-write, you've just replaced last night's good backup with a half-written, unrestorable file — and you won't know until the night you need it. Writing to a &lt;code&gt;.partial&lt;/code&gt; path and &lt;code&gt;mv&lt;/code&gt;-ing into place only on a clean exit means a failed or timed-out run leaves the previous good backup untouched. A &lt;code&gt;mv&lt;/code&gt; on the same filesystem is atomic; the redirect is not.&lt;/p&gt;

&lt;p&gt;For commands that talk to the network, layer the tool's own timeout underneath — &lt;code&gt;curl --max-time&lt;/code&gt;, &lt;code&gt;ssh -o ConnectTimeout&lt;/code&gt;, a &lt;code&gt;net_read_timeout&lt;/code&gt; on the dump. Those fire first and fail cleanly. &lt;code&gt;timeout&lt;/code&gt; is the outer hard stop for the night the inner one doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to the nine days
&lt;/h2&gt;

&lt;p&gt;A timeout is what makes a lock &lt;em&gt;safe&lt;/em&gt;. Locking a job to a single instance stops overlap, but a hang inside the locked job holds that lock forever — which is precisely how my nine-day gap happened. Bound the runtime and the lock always gets released, on a deadline, every time, and the failure becomes a loud &lt;code&gt;124&lt;/code&gt; in the log instead of a silent gap you discover during a restore.&lt;/p&gt;

&lt;p&gt;Full script with the exit-code branching and the FAQ on timing out a whole pipeline: &lt;a href="https://bashsnippets.xyz/snippets/bash-timeout-command" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-timeout-command&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lock that made my hang invisible is &lt;a href="https://bashsnippets.xyz/snippets/bash-flock-single-instance" rel="noopener noreferrer"&gt;flock&lt;/a&gt;, and the third guard is &lt;a href="https://bashsnippets.xyz/snippets/bash-retry-with-backoff" rel="noopener noreferrer"&gt;retry with backoff&lt;/a&gt;. The &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Hardened Cron Wrapper Generator&lt;/a&gt; composes all three, &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron" rel="noopener noreferrer"&gt;Bash Scripts That Survive Cron&lt;/a&gt; walks the whole decision, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>A Cron Job Took Our Server to Load 41 by Attacking Itself</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 23 Jun 2026 00:18:35 +0000</pubDate>
      <link>https://dev.to/bashsnippets/a-cron-job-took-our-server-to-load-41-by-attacking-itself-3p6g</link>
      <guid>https://dev.to/bashsnippets/a-cron-job-took-our-server-to-load-41-by-attacking-itself-3p6g</guid>
      <description>&lt;p&gt;A &lt;code&gt;*/1&lt;/code&gt; rsync took our staging box to a load average of 41 one afternoon, and it took me longer than I want to admit to work out why. The sync normally finished in about twenty seconds. That day the backup target's NFS mount went sluggish, the sync started taking ninety seconds, and cron — which does not know or care whether the last run is still going — launched a fresh copy every single minute on top of it.&lt;/p&gt;

&lt;p&gt;Inside ten minutes there were a half-dozen rsyncs all reading the same tree off the same slow disk, each one making the disk slower, each new minute adding another. The box wasn't under attack. It was attacking itself, one polite copy at a time. The thing that stung was that nothing was &lt;em&gt;broken&lt;/em&gt; — every individual rsync was correct, the disk eventually recovered on its own, and the only reason it became an outage is that cron has no concept of "the last one is still running."&lt;/p&gt;

&lt;p&gt;That's the trap with scheduled jobs: a command that's perfectly fine when you run it by hand can take down a server the first time it runs longer than its interval with nobody watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix everyone reaches for first is the wrong one
&lt;/h2&gt;

&lt;p&gt;The instinct is a PID file: write &lt;code&gt;$$&lt;/code&gt; to &lt;code&gt;/var/run/job.pid&lt;/code&gt; on start, check whether that file exists on the next run, bail if it does. It almost works. Then one run gets &lt;code&gt;kill -9&lt;/code&gt;'d, or the box reboots mid-job, and the PID file is left behind pointing at a process that died on Tuesday. Now every future run sees a "lock" owned by a PID that no longer exists, and the job never runs again — the opposite failure, just as silent.&lt;/p&gt;

&lt;p&gt;There's also a race between the check and the write, and the times you most need the lock to be clean are exactly the times cleanup didn't happen, because the process died before it could clean up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;flock&lt;/code&gt; has none of that. The lock isn't a file you create and delete — it's a lock the kernel holds on an &lt;em&gt;open file descriptor&lt;/em&gt;, and the kernel releases it automatically the instant that descriptor closes. The process exiting closes it. So does crashing. So does &lt;code&gt;kill -9&lt;/code&gt;. There is no state to leave behind, which is the entire reason it survives the failure modes a PID file can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The single-instance pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Script: backup-with-lock.sh&lt;/span&gt;
&lt;span class="c"&gt;# Purpose: Stop a cron job from overlapping itself when one run runs long&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CHECK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✓"&lt;/span&gt;
&lt;span class="nv"&gt;CROSS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✗"&lt;/span&gt;

&lt;span class="c"&gt;# /run/lock is tmpfs, cleared cleanly on reboot. Never /tmp — temp-cleaners&lt;/span&gt;
&lt;span class="c"&gt;# delete files there, and a deleted lock mid-run lets a second copy run.&lt;/span&gt;
&lt;span class="nv"&gt;LOCK_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/run/lock/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.lock"&lt;/span&gt;

&lt;span class="c"&gt;# The &amp;gt; opens (and creates) the lock file on fd 200 and holds it open for the&lt;/span&gt;
&lt;span class="c"&gt;# whole script. The lock lives on this descriptor, not on the file existing.&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;200&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCK_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# -n = non-blocking: if a previous run still holds the lock, give up now&lt;/span&gt;
&lt;span class="c"&gt;# instead of queueing another copy behind it.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; flock &lt;span class="nt"&gt;-n&lt;/span&gt; 200&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; previous run still active — skipping"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi

&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;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; lock acquired — starting"&lt;/span&gt;
rsync &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--delete&lt;/span&gt; /data/ /mnt/backup/data/
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; finished — kernel releases the lock on exit"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two lines doing the work are &lt;code&gt;exec 200&amp;gt;"$LOCK_FILE"&lt;/code&gt; and &lt;code&gt;flock -n 200&lt;/code&gt;. The first opens the lock file on a descriptor that stays open for the life of the process. The second tries to grab the lock without waiting; if a sibling process already holds it, &lt;code&gt;flock&lt;/code&gt; returns non-zero, we log it and exit &lt;code&gt;0&lt;/code&gt; — a skipped run is normal, not an error, so we don't want it lighting up cron's mail.&lt;/p&gt;

&lt;p&gt;Notice there is no cleanup. No &lt;code&gt;trap&lt;/code&gt; to remove a PID file, no &lt;code&gt;rm&lt;/code&gt; at the end. When this script exits for any reason, fd 200 closes and the lock is gone. That "for any reason" is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can lock a job without editing it at all
&lt;/h2&gt;

&lt;p&gt;If the misbehaving job is already deployed and you don't want to touch it, wrap it from the crontab line:&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;# Skip the run if the last one is still going&lt;/span&gt;
&lt;span class="k"&gt;*&lt;/span&gt;/1 &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; /usr/bin/flock &lt;span class="nt"&gt;-n&lt;/span&gt; /run/lock/sync.lock /usr/local/bin/sync.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;flock&lt;/code&gt; runs &lt;code&gt;sync.sh&lt;/code&gt; only if it can grab the lock; if last minute's run is still holding it, this minute's run exits immediately and does nothing. It's the fastest retrofit for a job that's already on fire — no redeploy.&lt;/p&gt;

&lt;p&gt;One thing worth burning into memory: &lt;code&gt;-n&lt;/code&gt; skips, &lt;code&gt;-w 30&lt;/code&gt; waits up to thirty seconds then gives up, and a &lt;em&gt;bare&lt;/em&gt; &lt;code&gt;flock&lt;/code&gt; with neither blocks forever. On a fast cron schedule that bare form turns your "skipped" runs into a pile of stuck processes — the exact thing you were trying to prevent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually mattered
&lt;/h2&gt;

&lt;p&gt;The load-41 afternoon ended the moment I wrapped that rsync in &lt;code&gt;flock -n&lt;/code&gt;. The slow NFS mount was still slow, but now exactly one sync ran at a time and the extras skipped harmlessly until the disk recovered. Locking didn't fix the slow disk — it stopped a transient slow disk from becoming a self-inflicted outage. That's the difference between a script that works when you run it and one that survives unattended.&lt;/p&gt;

&lt;p&gt;A lock alone isn't the whole story, though. If the locked job itself &lt;em&gt;hangs&lt;/em&gt;, it holds the lock forever and every future run skips — so the job silently stops running and you find out days later. That's why locking pairs with bounding runtime with &lt;code&gt;timeout&lt;/code&gt; and retrying transient failures.&lt;/p&gt;

&lt;p&gt;Full script, the &lt;code&gt;-n&lt;/code&gt; vs &lt;code&gt;-w&lt;/code&gt; decision, and the FAQ on where the lock file should live: &lt;a href="https://bashsnippets.xyz/snippets/bash-flock-single-instance" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-flock-single-instance&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're hardening a cron job, the next two guards are &lt;a href="https://bashsnippets.xyz/snippets/bash-timeout-command" rel="noopener noreferrer"&gt;timeout&lt;/a&gt; and &lt;a href="https://bashsnippets.xyz/snippets/bash-retry-with-backoff" rel="noopener noreferrer"&gt;retry with backoff&lt;/a&gt;; the &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Hardened Cron Wrapper Generator&lt;/a&gt; stitches all three into one wrapper, and the full reasoning is in &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron" rel="noopener noreferrer"&gt;Bash Scripts That Survive Cron&lt;/a&gt;. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>A Function Without local Overwrote My Variable and rm -rf Deleted the Wrong Directory</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Mon, 22 Jun 2026 02:46:38 +0000</pubDate>
      <link>https://dev.to/bashsnippets/a-function-without-local-overwrote-my-variable-and-rm-rf-deleted-the-wrong-directory-36ne</link>
      <guid>https://dev.to/bashsnippets/a-function-without-local-overwrote-my-variable-and-rm-rf-deleted-the-wrong-directory-36ne</guid>
      <description>&lt;p&gt;The deploy script had been running in production for four months without a problem. It built releases into a temp directory, ran some validation, and then cleaned up by removing whatever &lt;code&gt;$target&lt;/code&gt; pointed at. &lt;code&gt;$target&lt;/code&gt; was set near the top of the script to the current release directory — the one the running application was serving from. A helper function called &lt;code&gt;prepare()&lt;/code&gt; also used a variable named &lt;code&gt;target&lt;/code&gt;, because the person who wrote it (me, four months earlier) did not think about scope.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;prepare()&lt;/code&gt; built the new release into a temp directory. On a good run, it set &lt;code&gt;target&lt;/code&gt; to the temp path, did its work, and returned. The main script then did its cleanup at the end and removed &lt;code&gt;$target&lt;/code&gt; — which, after calling &lt;code&gt;prepare()&lt;/code&gt;, was the temp directory. That worked correctly for four months.&lt;/p&gt;

&lt;p&gt;Then a deploy failed partway through &lt;code&gt;prepare()&lt;/code&gt;. The temp directory was half-built. &lt;code&gt;target&lt;/code&gt; was now pointing at the half-built temp path. The main script caught the failure, started its cleanup, and ran &lt;code&gt;rm -rf "$target"&lt;/code&gt;. It removed the half-built temp directory, which was correct. But then it kept going — there was a second cleanup step that also used &lt;code&gt;$target&lt;/code&gt; and expected it to still be the release directory. By the time the script finished, it had removed the running application's release directory. The application restarted and found nothing to serve.&lt;/p&gt;

&lt;p&gt;The users noticed before I did. The restart loop was filling logs, the application was returning 502, and I was sitting in the deploy output trying to figure out what had gone wrong in a failure path I had tested against a stub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Variables in bash are global by default
&lt;/h2&gt;

&lt;p&gt;This is the single most surprising thing about bash functions if you have written code in almost any other language. In Python, a variable assigned inside a function is local to that function unless you explicitly declare it &lt;code&gt;global&lt;/code&gt;. In bash, it is the opposite. A variable assigned inside a function is visible — and writable — everywhere in the current shell unless you declare it &lt;code&gt;local&lt;/code&gt;.&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;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/srv/release/current"&lt;/span&gt;

prepare&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;    &lt;span class="c"&gt;# No local — this overwrites the global $target&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"building in &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

prepare
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"target is now: &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# Prints the temp dir, not /srv/release/current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function does exactly what it looks like — it sets &lt;code&gt;target&lt;/code&gt;. The problem is that it sets &lt;code&gt;target&lt;/code&gt; everywhere, not just inside itself. The caller's &lt;code&gt;target&lt;/code&gt; is gone.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;local&lt;/code&gt; confines the assignment to the function scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;prepare&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;target              &lt;span class="c"&gt;# confined to this function&lt;/span&gt;
  &lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;mktemp&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="si"&gt;)&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;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;            &lt;span class="c"&gt;# hand the value out via stdout&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;build_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;prepare&lt;span class="si"&gt;)&lt;/span&gt;        &lt;span class="c"&gt;# capture what the function echoed&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"built in: &lt;/span&gt;&lt;span class="nv"&gt;$build_dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"release dir still: &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# unchanged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;local target&lt;/code&gt; means: this variable exists only inside this function. When the function returns, the variable and its value vanish. The caller's &lt;code&gt;target&lt;/code&gt; is never touched. The habit I now enforce on every bash function I write: &lt;code&gt;local&lt;/code&gt; for every variable the function introduces, not just the ones I think might conflict. The conflict I do not predict is the one that deletes the wrong directory.&lt;/p&gt;

&lt;h2&gt;
  
  
  return is a status, not a value
&lt;/h2&gt;

&lt;p&gt;After the incident, I audited every function in the deploy script. I found a second bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;count_pending&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;n
  &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &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="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;      &lt;span class="c"&gt;# WRONG if n &amp;gt; 255&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

count_pending
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &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;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"queue has items"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;return&lt;/code&gt; sets an exit status. Exit statuses are a single byte: &lt;code&gt;0&lt;/code&gt; to &lt;code&gt;255&lt;/code&gt;. &lt;code&gt;return 300&lt;/code&gt; wraps to &lt;code&gt;44&lt;/code&gt;. For months the queue count had been above 255 on busy days, and the &lt;code&gt;$?&lt;/code&gt; check was comparing against a wrapped value. The logic had been wrong for months and had accidentally worked because the wrapped values still triggered the &lt;code&gt;gt 0&lt;/code&gt; condition. But any script making real decisions based on the actual count — how many workers to spin up, whether to page someone — would have been working with garbage.&lt;/p&gt;

&lt;p&gt;The correct pattern is to echo the value and capture it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;count_pending&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;n
  &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &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="si"&gt;)&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;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;         &lt;span class="c"&gt;# data goes to stdout&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;0          &lt;span class="c"&gt;# status: success&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;pending&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_pending&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"queue depth: &lt;/span&gt;&lt;span class="nv"&gt;$pending&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;return&lt;/code&gt; answers "did this succeed." &lt;code&gt;echo&lt;/code&gt; plus command substitution answers "what is the value." These are two different questions and bash gives you two different mechanisms for a reason. Mixing them is how a function that counts 300 items makes the caller think it counted 44.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arguments and the $@ quoting rule
&lt;/h2&gt;

&lt;p&gt;Inside a function, arguments arrive as positional parameters: &lt;code&gt;$1&lt;/code&gt;, &lt;code&gt;$2&lt;/code&gt;, all of them as &lt;code&gt;"$@"&lt;/code&gt;, the count as &lt;code&gt;$#&lt;/code&gt;. Quoting &lt;code&gt;"$@"&lt;/code&gt; is what keeps multi-word arguments intact:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;process_hosts&lt;span class="o"&gt;()&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;"processing &lt;/span&gt;&lt;span class="nv"&gt;$# &lt;/span&gt;&lt;span class="s2"&gt;hosts"&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;host &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  checking: &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

process_hosts &lt;span class="s2"&gt;"web-01"&lt;/span&gt; &lt;span class="s2"&gt;"db primary"&lt;/span&gt; &lt;span class="s2"&gt;"cache-02"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the quotes around &lt;code&gt;"$@"&lt;/code&gt;, &lt;code&gt;db primary&lt;/code&gt; splits into two loop iterations and you are back to the word-splitting problem from a different angle. The quoted &lt;code&gt;"$@"&lt;/code&gt; is the way bash passes an array of arguments through a function call with each element preserved.&lt;/p&gt;

&lt;p&gt;This matters most when you are writing wrapper functions — functions that receive arguments and pass them to another command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;run_with_retry&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;shift
  local &lt;/span&gt;&lt;span class="nv"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
  &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;attempt &amp;lt; retries&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
    &lt;span class="o"&gt;((&lt;/span&gt;attempt++&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"retry &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$retries&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;sleep &lt;/span&gt;2
  &lt;span class="k"&gt;done
  return &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;

run_with_retry 3 rsync &lt;span class="nt"&gt;-av&lt;/span&gt; &lt;span class="s2"&gt;"source dir/"&lt;/span&gt; remote:/dest/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"$@"&lt;/code&gt; after the &lt;code&gt;shift&lt;/code&gt; is everything after the retry count — the command and all its arguments, each preserved as a separate item even if they contain spaces. Without the quotes, &lt;code&gt;"source dir/"&lt;/code&gt; splits and &lt;code&gt;rsync&lt;/code&gt; receives the wrong arguments.&lt;/p&gt;

&lt;h2&gt;
  
  
  getopts for anything with flags
&lt;/h2&gt;

&lt;p&gt;For one or two fixed positional arguments, reading &lt;code&gt;$1&lt;/code&gt; and &lt;code&gt;$2&lt;/code&gt; directly is fine. The moment a function or script takes optional flags in any order, do not parse them by hand:&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;verbose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nv"&gt;output_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;getopts&lt;/span&gt; &lt;span class="s2"&gt;"vd:"&lt;/span&gt; opt&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$opt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;v&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;verbose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
    d&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;output_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OPTARG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"usage: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; [-v] [-d dir]"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;shift&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;OPTIND &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;   &lt;span class="c"&gt;# move past the flags to positional args&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The colon after &lt;code&gt;d&lt;/code&gt; marks it as requiring an argument, which arrives in &lt;code&gt;$OPTARG&lt;/code&gt;. &lt;code&gt;getopts&lt;/code&gt; handles flag bundling (&lt;code&gt;-vd dir&lt;/code&gt;), missing arguments (&lt;code&gt;-d&lt;/code&gt; with no path generates an error automatically), and unknown flags. Hand-rolled &lt;code&gt;$1&lt;/code&gt; parsing gets all of these wrong in subtle ways — it accepts &lt;code&gt;-d&lt;/code&gt; at the end without a value, it does not handle &lt;code&gt;-vd dir&lt;/code&gt;, and it requires the flags in a specific order.&lt;/p&gt;

&lt;p&gt;The deploy script that caused the incident had hand-rolled argument parsing. Among other things, it silently accepted a &lt;code&gt;--target&lt;/code&gt; flag with no value and proceeded with an empty string, which caused a different class of problem I had also not fully traced before the bigger incident made the whole thing visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the deploy script looks like now
&lt;/h2&gt;

&lt;p&gt;Every function declares &lt;code&gt;local&lt;/code&gt; for every variable. Values that need to cross function boundaries go through &lt;code&gt;echo&lt;/code&gt; and command substitution. Exit statuses communicate success or failure. &lt;code&gt;getopts&lt;/code&gt; handles the flags. There is a &lt;code&gt;trap&lt;/code&gt; on &lt;code&gt;EXIT&lt;/code&gt; that cleans up the temp directory using a local variable that only the cleanup function can see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;tmp_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TEMP_BUILD_DIR&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# local ref to the temp dir&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;$tmp_dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp_dir&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;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$tmp_dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap &lt;/span&gt;cleanup EXIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;$target&lt;/code&gt; variable in the main script is set once at the top and never touched by any function. Functions that need a temp directory create one, store it in a local variable, use it, and the cleanup trap handles removal. The variable naming conflict that caused the incident cannot happen because the pattern prevents it structurally.&lt;/p&gt;

&lt;p&gt;The application has been running cleanly since then. The deploy script has hit the failure path twice since the fix — different failures, unrelated causes — and both times the cleanup ran correctly and left the running release untouched.&lt;/p&gt;

&lt;p&gt;Full version with the local-scope fix, the echo-for-values pattern, the return-as-status trap, and a getopts template: &lt;a href="https://bashsnippets.xyz/snippets/bash-functions-arguments" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-functions-arguments&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A function that fails should fail loudly — wrap the script in &lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling" rel="noopener noreferrer"&gt;set -euo pipefail&lt;/a&gt; — and the &lt;a href="https://bashsnippets.xyz/tools/bash-boilerplate-generator" rel="noopener noreferrer"&gt;bash boilerplate generator&lt;/a&gt; can scaffold all of this with the right traps and argument parsing wired in from the start. The rest is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Alert Never Fired Because the Loop Skipped the Last Line of the File</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Fri, 19 Jun 2026 17:58:09 +0000</pubDate>
      <link>https://dev.to/bashsnippets/the-alert-never-fired-because-the-loop-skipped-the-last-line-of-the-file-3il8</link>
      <guid>https://dev.to/bashsnippets/the-alert-never-fired-because-the-loop-skipped-the-last-line-of-the-file-3il8</guid>
      <description>&lt;p&gt;We kept a plaintext file of hostnames, one per line, and a monitoring script read the file and pinged each host every five minutes. When a host failed to respond, the script sent an email alert. The system had been running for months and it worked — we had caught three actual outages with it, which gave us real confidence in the setup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app-07&lt;/code&gt; was added to the list on a Thursday afternoon. The engineer who added it was using VS Code on a Mac, and VS Code by default does not add a trailing newline to a file when you append to it using certain editing workflows. The file had ended in a newline before the edit. After the edit, the last line — &lt;code&gt;app-07&lt;/code&gt; — had no trailing newline.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;app-07&lt;/code&gt; went down the following Sunday afternoon at 2:17pm. The monitoring script ran at 2:20, 2:25, 2:30, all the way through the evening. No alert ever fired. The on-call engineer found out at 8pm when a client emailed. The system had been down for almost six hours.&lt;/p&gt;

&lt;p&gt;When I looked at the script, the bug was immediately obvious once I knew what to look for. But I had written that script, I had tested it, and I had been looking at the monitoring confirmation emails for months without ever noticing. The confirmation email listed the hosts it checked. &lt;code&gt;app-07&lt;/code&gt; was never in the list. I had been reading those emails without actually counting the hosts. I just scanned for the OK lines and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why read drops the last line
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;read&lt;/code&gt; returns a success exit status when it reads a line and finds the newline that terminates it. When the file does not end in a newline, &lt;code&gt;read&lt;/code&gt; still populates the variable with the final line's content, but it returns a non-zero (failure) exit status because it hit end-of-file before finding a terminator. A &lt;code&gt;while read host&lt;/code&gt; loop checks the return status to decide whether to execute the loop body. On the final, newline-less line, &lt;code&gt;read&lt;/code&gt; puts &lt;code&gt;app-07&lt;/code&gt; into &lt;code&gt;host&lt;/code&gt; and then returns failure. The &lt;code&gt;while&lt;/code&gt; loop sees failure and exits without running the body. The content is there. The variable is populated. The loop throws it away.&lt;/p&gt;

&lt;p&gt;This behavior is documented in the POSIX spec for &lt;code&gt;read&lt;/code&gt;. It is not a bash quirk. Any POSIX shell handles the missing-final-newline case this way. A plain &lt;code&gt;while read line&lt;/code&gt; loop is incorrect for any file you do not personally control the formatting of, which in practice means nearly any file.&lt;/p&gt;

&lt;p&gt;The fix is one extra clause:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Script: check-hosts.sh&lt;/span&gt;
&lt;span class="c"&gt;# Purpose: ping every host in a file, including a newline-less final line&lt;/span&gt;
&lt;span class="c"&gt;# Usage: ./check-hosts.sh hosts.txt&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CHECK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✓"&lt;/span&gt;
&lt;span class="nv"&gt;CROSS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✗"&lt;/span&gt;
&lt;span class="nv"&gt;HOST_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;:?Usage:&lt;span class="p"&gt; check-hosts.sh &amp;lt;host-file&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; host &lt;span class="o"&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;$host&lt;/span&gt;&lt;span class="s2"&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;do&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="se"&gt;\#&lt;/span&gt;&lt;span class="k"&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="k"&gt;continue
  if &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="nt"&gt;-W2&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &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;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; up:   &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; down: &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOST_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;|| [[ -n "$host" ]]&lt;/code&gt; says: if &lt;code&gt;read&lt;/code&gt; returned failure but the variable is non-empty, run the loop body anyway. That is precisely the leftover-final-line case. &lt;code&gt;read&lt;/code&gt; failed because it hit end-of-file, but it populated &lt;code&gt;host&lt;/code&gt; with &lt;code&gt;app-07&lt;/code&gt; before returning. The &lt;code&gt;||&lt;/code&gt; catches it. &lt;code&gt;app-07&lt;/code&gt; gets pinged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What IFS= and -r actually do
&lt;/h2&gt;

&lt;p&gt;You see &lt;code&gt;while IFS= read -r line&lt;/code&gt; written in every correct read-loop example, and it is worth being specific about what each piece prevents because both have their own failure mode.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IFS=&lt;/code&gt; sets the field separator to empty for the duration of the &lt;code&gt;read&lt;/code&gt; command. Without it, &lt;code&gt;read&lt;/code&gt; strips leading and trailing whitespace from each line. A hostname like &lt;code&gt;app-07&lt;/code&gt; (with leading spaces, which some editors produce) becomes &lt;code&gt;app-07&lt;/code&gt;, which might be correct. An indented config value, a Python-style YAML string, a log line that starts with spaces for alignment — all of these are silently modified. Setting &lt;code&gt;IFS=&lt;/code&gt; tells &lt;code&gt;read&lt;/code&gt; to take the line exactly as it appears.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-r&lt;/code&gt; prevents &lt;code&gt;read&lt;/code&gt; from interpreting backslash sequences. Without &lt;code&gt;-r&lt;/code&gt;, a line like &lt;code&gt;C:\temp\logs&lt;/code&gt; has its backslashes consumed as escape characters and arrives as &lt;code&gt;C:templogs&lt;/code&gt;. This matters less for hostname files and enormously for any script that processes Windows paths, config files that use backslash as a line-continuation character, or log files from mixed-OS environments. The &lt;code&gt;-r&lt;/code&gt; flag is essentially free protection; there is no reason not to include it.&lt;/p&gt;

&lt;p&gt;A bare &lt;code&gt;read line&lt;/code&gt; without either flag silently mangles both whitespace and backslashes. The script works correctly on clean input and produces wrong output on input with edge cases. The wrong output does not produce an error. You find out when the data that mattered was the indented or backslash-containing kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subshell trap that kills your counters
&lt;/h2&gt;

&lt;p&gt;This is the one that is most likely to make you question your sanity:&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;# This looks correct. It is not.&lt;/span&gt;
&lt;span class="nv"&gt;fails&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;cat &lt;/span&gt;hosts.txt | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; host&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="nt"&gt;-W2&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;fails++&lt;span class="o"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Total failures: &lt;/span&gt;&lt;span class="nv"&gt;$fails&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# Always prints 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipe creates a subshell for the right side. The &lt;code&gt;while&lt;/code&gt; loop runs inside that subshell. &lt;code&gt;fails&lt;/code&gt; increments correctly inside the subshell. When the subshell exits, the parent shell's &lt;code&gt;fails&lt;/code&gt; is still &lt;code&gt;0&lt;/code&gt;, because the increment happened in a different process. The parent echo sees the original value.&lt;/p&gt;

&lt;p&gt;This catches people because the loop body itself works — the pings happen, the increment logic is correct — but any state the loop was supposed to accumulate for later use is silently discarded. I spent forty minutes on a version of this problem before I remembered that pipes create subshells. It is the kind of thing that feels like a bash bug until you understand that it is behaving exactly as documented.&lt;/p&gt;

&lt;p&gt;The fix is to redirect the file into the loop instead of piping into it:&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;fails&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; host &lt;span class="o"&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;$host&lt;/span&gt;&lt;span class="s2"&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;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="nt"&gt;-W2&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;fails++&lt;span class="o"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; hosts.txt
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Total failures: &lt;/span&gt;&lt;span class="nv"&gt;$fails&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# Now correct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;done &amp;lt; hosts.txt&lt;/code&gt; feeds the file to the loop's stdin without a pipe. The loop runs in the current shell. &lt;code&gt;fails&lt;/code&gt; accumulates in the current shell. The echo sees the real count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why for loop is wrong for this
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Never do this — iterates words, not lines&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;line &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;hosts.txt&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;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&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;&lt;code&gt;$(cat hosts.txt)&lt;/code&gt; is command substitution. Bash captures the text output and word-splits it on IFS — spaces, tabs, newlines. For a file with one hostname per line and no spaces in the hostnames, this accidentally produces the right behavior. For any file with spaces — log lines, config values, paths with spaces, anything a non-developer might have generated — it splits lines into fragments and each fragment becomes a loop iteration.&lt;/p&gt;

&lt;p&gt;There is no version of &lt;code&gt;for line in $(cat file)&lt;/code&gt; that is correct for reading lines. The right tool is always &lt;code&gt;while IFS= read -r line || [[ -n "$line" ]]; do ... done &amp;lt; file&lt;/code&gt;. The for loop is the right tool for iterating a known list that you control directly, not for reading file content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monitoring system, after the fix
&lt;/h2&gt;

&lt;p&gt;After the &lt;code&gt;app-07&lt;/code&gt; incident we made three changes. The obvious one was fixing the read loop with the &lt;code&gt;|| [[ -n "$host" ]]&lt;/code&gt; guard. The second was adding a sanity check at the top of the script that counted the lines in the hosts file and compared it to the number of hosts the loop actually processed — a mismatch meant the file was malformed or something else was wrong. The third was adding a nightly email that included the count of hosts checked, not just the status of each one, so a future addition to the file that somehow got lost would show up as "expected 12 hosts, checked 11."&lt;/p&gt;

&lt;p&gt;The confirmation email count was something I should have had from the start. If I had been looking at "checked 11/12 hosts" instead of a list of OK lines, I would have noticed &lt;code&gt;app-07&lt;/code&gt; missing on the first night. The monitoring was working. The observability of the monitoring was not.&lt;/p&gt;

&lt;p&gt;Full version with CSV-field parsing, comment-skipping, and the subshell-safe redirect form: &lt;a href="https://bashsnippets.xyz/snippets/bash-read-file-line-by-line" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-read-file-line-by-line&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To iterate a list of files rather than a file's contents, reach for a &lt;a href="https://bashsnippets.xyz/snippets/bash-for-loop-examples" rel="noopener noreferrer"&gt;for loop&lt;/a&gt; instead, and wrap anything that acts on what it reads in &lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling" rel="noopener noreferrer"&gt;set -euo pipefail&lt;/a&gt;. More at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>A For Loop Skipped Every File With a Space and Called the Backup a Success</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 18 Jun 2026 19:29:49 +0000</pubDate>
      <link>https://dev.to/bashsnippets/a-for-loop-skipped-every-file-with-a-space-and-called-the-backup-a-success-392e</link>
      <guid>https://dev.to/bashsnippets/a-for-loop-skipped-every-file-with-a-space-and-called-the-backup-a-success-392e</guid>
      <description>&lt;p&gt;The nightly backup looped over &lt;code&gt;for f in $(ls /data/exports)&lt;/code&gt; and copied each file to a backup volume. It exited clean every night. For three weeks, green exit codes, no errors, nothing in the logs to suggest anything was wrong. The backup script had been written by someone who left the company six months before I started, and it had never been tested against files with spaces in their names because the original export directory only ever had files like &lt;code&gt;Q3.xlsx&lt;/code&gt; and &lt;code&gt;report-final.xlsx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then someone generated &lt;code&gt;Q3 final.xlsx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It took three weeks because that file was generated once a quarter and the next time someone needed it was a quarter later. The person who needed it was the CFO. The CFO does not particularly enjoy being told that the backup system that was supposed to protect the quarterly export has been silently failing for an indeterminate period of time and we are not sure which other files it missed.&lt;/p&gt;

&lt;p&gt;I know the specific backup had been running for three weeks because that was the date the file appeared in the exports directory. Every night since then, the loop had been splitting &lt;code&gt;Q3 final.xlsx&lt;/code&gt; into two items — &lt;code&gt;Q3&lt;/code&gt; and &lt;code&gt;final.xlsx&lt;/code&gt; — trying to copy two paths that did not exist, logging two harmless "no such file or directory" lines that nobody read, and moving on. Every file without a space in its name backed up fine. The script looked correct because most of the time it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why $(ls) word-splits
&lt;/h2&gt;

&lt;p&gt;When you write &lt;code&gt;for f in $(ls /data/exports)&lt;/code&gt;, bash runs &lt;code&gt;ls&lt;/code&gt;, captures its text output, and splits it on IFS — the internal field separator, which defaults to spaces, tabs, and newlines. Filenames are just text in the output of &lt;code&gt;ls&lt;/code&gt;. A file named &lt;code&gt;Q3 final.xlsx&lt;/code&gt; is one filename, but &lt;code&gt;ls&lt;/code&gt; outputs it as the string &lt;code&gt;Q3 final.xlsx&lt;/code&gt;, and bash splits that string on the space into two separate items before the loop ever starts.&lt;/p&gt;

&lt;p&gt;This is not a bug in &lt;code&gt;ls&lt;/code&gt;. This is not a bug in bash. This is exactly what &lt;code&gt;$( )&lt;/code&gt; does to any command's output — it captures text and bash processes it as text. The problem is that filenames are not reliably text-safe; they can contain any character except null and the path separator. Spaces are common. Tabs are less common but legal. Newlines are technically legal. Treating command output as a filename list breaks the moment any of those show up.&lt;/p&gt;

&lt;p&gt;The fix is to stop treating command output as a filename list and let bash build the list from the filesystem directly:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Script: backup-exports.sh&lt;/span&gt;
&lt;span class="c"&gt;# Purpose: copy export files without losing ones with spaces in their names&lt;/span&gt;
&lt;span class="c"&gt;# Usage: ./backup-exports.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CHECK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✓"&lt;/span&gt;
&lt;span class="nv"&gt;CROSS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"✗"&lt;/span&gt;
&lt;span class="nv"&gt;SRC_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/data/exports"&lt;/span&gt;
&lt;span class="nv"&gt;DEST_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/backup/exports"&lt;/span&gt;
&lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; nullglob   &lt;span class="c"&gt;# empty glob expands to nothing, not the literal pattern&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.xlsx&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &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;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; backed up: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &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;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;for f in "$SRC_DIR"/*.xlsx&lt;/code&gt; asks bash to expand the glob. Bash talks directly to the filesystem and gets back a properly-separated list of matching paths. No command output, no text splitting, no ambiguity about what the separator is. A file named &lt;code&gt;Q3 final.xlsx&lt;/code&gt; stays one item because it was never turned into text and split back apart. The &lt;code&gt;--&lt;/code&gt; before &lt;code&gt;"$f"&lt;/code&gt; tells &lt;code&gt;cp&lt;/code&gt; to stop reading flags, so a filename that starts with a dash does not get interpreted as a &lt;code&gt;cp&lt;/code&gt; option.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second half: quoting on use
&lt;/h2&gt;

&lt;p&gt;The glob fixes the loop header. Quoting fixes the point of use. These are separate.&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;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.xlsx&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;    &lt;span class="c"&gt;# WRONG — $f re-splits here&lt;/span&gt;
  &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;  &lt;span class="c"&gt;# RIGHT — the quotes prevent the split&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with a correct glob, an unquoted &lt;code&gt;$f&lt;/code&gt; in the &lt;code&gt;cp&lt;/code&gt; command re-splits on spaces. Bash has already expanded the variable to &lt;code&gt;Q3 final.xlsx&lt;/code&gt;, but when you use it unquoted, the shell processes word splitting again on that value and &lt;code&gt;cp&lt;/code&gt; receives two arguments: &lt;code&gt;Q3&lt;/code&gt; and &lt;code&gt;final.xlsx&lt;/code&gt;. The quotes around &lt;code&gt;"$f"&lt;/code&gt; tell bash to pass the entire value as a single argument. The rule is: glob over parse to build the list, quote on use to keep each item intact.&lt;/p&gt;

&lt;p&gt;I have seen this mistake repeated in scripts at three different companies. In each case the scripts had been running for months or years and the problem was invisible because the majority of files had no spaces. The ones that did — client names, quarterly reports, anything a non-technical person named — were being silently skipped or mishandled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranges and counters: where the brace trap hides
&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;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5

&lt;span class="c"&gt;# This does NOT produce a range — it produces the literal string {1..5}&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# This works — C-style, evaluates variables at runtime&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;i &lt;span class="o"&gt;=&lt;/span&gt; 1&lt;span class="p"&gt;;&lt;/span&gt; i &amp;lt;&lt;span class="o"&gt;=&lt;/span&gt; n&lt;span class="p"&gt;;&lt;/span&gt; i++&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&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;Brace expansion happens before variable expansion in bash's order of operations. By the time &lt;code&gt;$n&lt;/code&gt; is replaced with its value, the brace expansion step has already passed and &lt;code&gt;{1..$n}&lt;/code&gt; is just a string. This is the kind of thing that fails silently in a test environment where the count is small and hardcoded, and causes weird output in production where it is a variable.&lt;/p&gt;

&lt;p&gt;The C-style loop is the correct form any time the bound is a variable. It evaluates at runtime and handles arithmetic naturally. If the count is genuinely fixed at write time, &lt;code&gt;{1..10}&lt;/code&gt; works fine. If it is ever going to be a variable, use &lt;code&gt;for (( ))&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Arrays: the original bug wearing a different hat
&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;servers&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"web-01"&lt;/span&gt; &lt;span class="s2"&gt;"db primary"&lt;/span&gt; &lt;span class="s2"&gt;"cache-02"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# WRONG — word-splits "db primary" into two iterations&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;servers&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# RIGHT — quotes keep each element intact&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;servers&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&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;&lt;code&gt;"${servers[@]}"&lt;/code&gt; with the quotes and &lt;code&gt;[@]&lt;/code&gt; is the form that preserves each element as a single item regardless of what is in it. Without the quotes, bash word-splits the array expansion and &lt;code&gt;db primary&lt;/code&gt; becomes two separate loop iterations — neither of which is a real hostname. This is exactly the same word-splitting mechanism as the &lt;code&gt;$(ls)&lt;/code&gt; problem, just manifesting in arrays instead of command output.&lt;/p&gt;

&lt;p&gt;Once you internalize that word-splitting happens wherever an unquoted variable or expansion appears, the rule becomes one rule instead of several: always quote expansions. The specific context changes; the mechanism does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nullglob case
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; nullglob
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; /data/exports/&lt;span class="k"&gt;*&lt;/span&gt;.xlsx&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &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;$f&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;Without &lt;code&gt;shopt -s nullglob&lt;/code&gt;, if no &lt;code&gt;.xlsx&lt;/code&gt; files exist, the glob &lt;code&gt;*.xlsx&lt;/code&gt; does not expand — it stays as the literal string &lt;code&gt;*.xlsx&lt;/code&gt;. The loop runs once with &lt;code&gt;f&lt;/code&gt; set to the literal string &lt;code&gt;/data/exports/*.xlsx&lt;/code&gt;. Your script then tries to process a file with that exact path, which does not exist, and produces an error or silently does nothing depending on what you do with it.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;nullglob&lt;/code&gt; tells bash to expand a non-matching glob to nothing (an empty list), so the loop simply does not run. This is almost always the right behavior when you are iterating files that might not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened to the Q3 export
&lt;/h2&gt;

&lt;p&gt;We recovered it from the CFO's local machine, where she had downloaded it before the backup was supposed to preserve it. The fix to the script took four minutes. The conversation about why the backup system had been failing silently for three weeks took longer. The monitoring that we added afterwards — a nightly check that the backup directory has at least as many files as the source directory — took another twenty minutes.&lt;/p&gt;

&lt;p&gt;The monitoring should have been there from the start. So should the glob. So should the &lt;code&gt;set -euo pipefail&lt;/code&gt; that would have made the copy failures loud instead of silent. These are things you add before something breaks, and the only reason to know you need them is to have seen, or caused, or read about what happens when they are missing.&lt;/p&gt;

&lt;p&gt;Full examples with the safe glob, counter, C-style loop, array form, and nullglob guard: &lt;a href="https://bashsnippets.xyz/snippets/bash-for-loop-examples" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-for-loop-examples&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For reading a file's lines one at a time, a for loop is the wrong tool — use &lt;a href="https://bashsnippets.xyz/snippets/bash-read-file-line-by-line" rel="noopener noreferrer"&gt;while IFS= read -r&lt;/a&gt; — and wrap any loop that touches real files in &lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling" rel="noopener noreferrer"&gt;set -euo pipefail&lt;/a&gt;. The rest is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
