<?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>grep "error|failed" Found Nothing in a Log That Said 'backup failed'</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/grep-errorfailed-found-nothing-in-a-log-that-said-backup-failed-n36</link>
      <guid>https://dev.to/bashsnippets/grep-errorfailed-found-nothing-in-a-log-that-said-backup-failed-n36</guid>
      <description>&lt;p&gt;I wrote a three-line backup log on my own machine: a start line, &lt;code&gt;tar: /var/www/uploads: Cannot open: Permission denied&lt;/code&gt;, and &lt;code&gt;backup failed (exit 2)&lt;/code&gt;. Then I ran the check a lot of people keep as a weekly ritual, the one that looks like it asks "did anything go wrong?": &lt;code&gt;grep "error|failed" backup.log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;GNU grep 3.12 printed nothing and exited 1. The last line of the log says &lt;code&gt;failed&lt;/code&gt; in plain text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;grep -E "error|failed" backup.log&lt;/code&gt; printed that line and exited 0.&lt;/p&gt;

&lt;p&gt;Nothing about the first command is malformed, so there is no error message. Exit 1 means "no lines matched", which is exactly what grep returns for a genuinely clean log. A monitoring check wired to that exit code reports all clear on a log full of failures, every single run, and the empty output does not start an investigation. It ends one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipe that isn't a pipe
&lt;/h2&gt;

&lt;p&gt;grep's default engine is Basic Regular Expressions, and in BRE the pipe is not alternation. It is a literal character. &lt;code&gt;error|failed&lt;/code&gt; does not mean "error or failed"; it means the twelve-character string &lt;code&gt;error|failed&lt;/code&gt;, pipe included, which no logger has ever written. The same goes for &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt; and parentheses: in BRE they are ordinary characters unless you backslash-escape them. Alternation the way every other regex engine behaves needs &lt;code&gt;-E&lt;/code&gt;, Extended Regular Expressions — the mode &lt;code&gt;egrep&lt;/code&gt; has been shorthand for since long before most of us started typing 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="c"&gt;# BRE (default): the pipe is a literal character — matches nothing, exits 1&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"error|failed"&lt;/span&gt; backup.log

&lt;span class="c"&gt;# ERE: the pipe means OR — the search you actually meant&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"error|failed"&lt;/span&gt; backup.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GNU grep also accepts &lt;code&gt;grep "error\|failed"&lt;/code&gt; in BRE mode — on my machine that matched and exited 0 — but the escaped pipe is a GNU extension that POSIX does not define, so it is a habit that stops working on the systems that do not carry GNU grep. &lt;code&gt;-E&lt;/code&gt; is the portable spelling.&lt;/p&gt;

&lt;p&gt;The deeper trap is in the exit codes. grep exits 0 on a match, 1 on no match and 2 on an actual error; pointed at a file that did not exist, it printed &lt;code&gt;No such file or directory&lt;/code&gt; and exited 2. An impossible pattern is not an error. "No matching lines" and "you searched for a string that cannot exist" produce identical output: none, with the same status. A false negative is the worst failure a search tool has, because nobody goes looking for a problem that grep has already said is not there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alias is a crutch
&lt;/h2&gt;

&lt;p&gt;The tempting fix is &lt;code&gt;alias grep='grep -E'&lt;/code&gt; in your shell rc file, and it does make the interactive ritual behave. It also does nothing anywhere that matters. Aliases apply only to interactive shells, so every cron job and script on the same box still runs BRE grep. The alias does not exist on any machine you have not configured, which is most of the machines you will ever SSH into. And it trains your fingers to type patterns that silently degrade the moment it is absent. The durable fix is knowing which engine a grep runs under and what its silence means before you trust it — and writing &lt;code&gt;-E&lt;/code&gt; into the script itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the builder shows the engine and the empty result
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/tools/grep-pattern-builder" rel="noopener noreferrer"&gt;grep Pattern Builder&lt;/a&gt; assembles the command live as you type: pattern, path, and an optional comma-separated list of file types that it turns into &lt;code&gt;--include&lt;/code&gt; flags, so a log search does not wade through binaries. Toggles cover the flags that earn their keep — case-insensitive, recursive, line numbers, invert, count, filenames-only, whole-word, quiet and &lt;code&gt;-B&lt;/code&gt;/&lt;code&gt;-A&lt;/code&gt; context lines — and every combination produces a plain-English sentence saying what the command will do before you run it.&lt;/p&gt;

&lt;p&gt;The engine picker sits front and centre: BRE, &lt;code&gt;-E&lt;/code&gt; and &lt;code&gt;-P&lt;/code&gt;, each with a note about what is literal where. The BRE note says it outright: &lt;code&gt;+ ? | ( )&lt;/code&gt; are literal unless escaped. The &lt;code&gt;-P&lt;/code&gt; note warns that Perl-compatible regex is GNU-only and fails on macOS and BSD grep, the portability cousin of the same trap: a &lt;code&gt;\d&lt;/code&gt; pattern that works on a Debian server errors out on a Mac.&lt;/p&gt;

&lt;p&gt;There are guardrails for mistakes people ship. It turns on &lt;code&gt;-r&lt;/code&gt; when the path looks like a directory, because plain grep pointed at a directory refuses with "Is a directory", and in a script with stderr redirected that refusal is invisible. It drops &lt;code&gt;-n&lt;/code&gt; when &lt;code&gt;-c&lt;/code&gt;, &lt;code&gt;-l&lt;/code&gt; or &lt;code&gt;-q&lt;/code&gt; is active, because those flags change the output mode and line numbers stop meaning anything.&lt;/p&gt;

&lt;p&gt;The panel that makes the ritual trustworthy is the live tester. Paste a few sample lines, including one real failure line from your actual log, and it highlights which lines the current pattern matches, with a count. In BRE mode it treats a bare pipe literally, the way grep does, so &lt;code&gt;error|failed&lt;/code&gt; against a pasted &lt;code&gt;backup failed&lt;/code&gt; line highlights nothing: zero out of one, a number that makes you look up. Switch to &lt;code&gt;-E&lt;/code&gt; and the line lights. The preview runs on JavaScript's regex engine, which is close to ERE and PCRE for everyday patterns — treat it as a check on your pattern, not a replacement for running grep.&lt;/p&gt;

&lt;p&gt;One unescaped character turned a failure search into a search for a string that cannot exist, and grep answered that question with perfect accuracy. The command was valid, ran clean, exited 1, and reported nothing wrong.&lt;/p&gt;

&lt;p&gt;Build the command and read the explanation before you trust it: &lt;a href="https://bashsnippets.xyz/tools/grep-pattern-builder" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools/grep-pattern-builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/snippets/search-files-for-text-grep" rel="noopener noreferrer"&gt;Search Files for Text snippet&lt;/a&gt; covers the grep patterns worth keeping, &lt;a href="https://bashsnippets.xyz/snippets/delete-old-log-files" rel="noopener noreferrer"&gt;Delete Old Log Files&lt;/a&gt; handles the other half of log hygiene, 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>webdev</category>
      <category>tools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Moved a Script to Another Directory on PATH. bash Said 'No such file or directory' and Exited 127.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-moved-a-script-to-another-directory-on-path-bash-said-no-such-file-or-directory-and-exited-127-36hj</link>
      <guid>https://dev.to/bashsnippets/i-moved-a-script-to-another-directory-on-path-bash-said-no-such-file-or-directory-and-exited-127-36hj</guid>
      <description>&lt;p&gt;One bash process, one small demo script called &lt;code&gt;report&lt;/code&gt;, and a &lt;code&gt;mv&lt;/code&gt; between two directories that are both on &lt;code&gt;PATH&lt;/code&gt;. I set it up in a scratch tmpfs mounted over &lt;code&gt;/opt&lt;/code&gt; inside &lt;code&gt;unshare -rm&lt;/code&gt;, with &lt;code&gt;PATH&lt;/code&gt; starting &lt;code&gt;/opt/jobs/bin:/opt/jobs/newbin&lt;/code&gt;, and ran it as a script. Inside the namespace I appear as root, which is why &lt;code&gt;ls&lt;/code&gt; says &lt;code&gt;root root&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ report
report 2.1: 14 jobs ok
exit=0
$ mv /opt/jobs/bin/report /opt/jobs/newbin/report
$ ls -l /opt/jobs/newbin/report
-rwxrwxr-x 1 root root 42 Oct  3 18:26 /opt/jobs/newbin/report
$ report
session.sh: line 5: /opt/jobs/bin/report: No such file or directory
exit=127
$ type -a report
report is /opt/jobs/newbin/report
$ hash -t report
/opt/jobs/bin/report
$ command -v report
/opt/jobs/bin/report
$ hash -r
$ report
report 2.1: 14 jobs ok
exit=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file exists, it is executable, and its new directory is on &lt;code&gt;PATH&lt;/code&gt;. bash ran the old path anyway, said &lt;code&gt;No such file or directory&lt;/code&gt;, and exited 127, the same code a typo gets. I ran the same sequence through an interactive &lt;code&gt;bash -i&lt;/code&gt; to be sure it was not a script quirk, and got &lt;code&gt;bash: /opt/jobs/bin/report: No such file or directory&lt;/code&gt;, exit 127.&lt;/p&gt;

&lt;p&gt;What makes it hard to trust your own eyes is the middle of that output. &lt;code&gt;type -a&lt;/code&gt; names the new location. &lt;code&gt;command -v&lt;/code&gt;, the check most scripts use to ask "is this installed", names the old one.&lt;/p&gt;

&lt;h2&gt;
  
  
  bash remembers where it found things
&lt;/h2&gt;

&lt;p&gt;The first time bash runs a command through a &lt;code&gt;PATH&lt;/code&gt; search, it stores the full path in a hash table, so the next call can skip the search. That is what &lt;code&gt;hash -t report&lt;/code&gt; prints. When the file moves, bash does not search again; it executes the remembered path, the kernel answers &lt;code&gt;ENOENT&lt;/code&gt;, and bash reports &lt;code&gt;No such file or directory&lt;/code&gt; with the stale path in the message.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;type -a&lt;/code&gt; searches &lt;code&gt;PATH&lt;/code&gt; fresh every time, because its job is to list every match. &lt;code&gt;command -v&lt;/code&gt; reports what bash &lt;em&gt;would run&lt;/em&gt;, and bash would run the hashed path. So the two most common "where is it" checks disagree, and the one that agrees with the error is the one that looks wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hash -r&lt;/code&gt; empties the table. A new terminal starts with an empty one. And there is a shell option made for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ shopt -s checkhash
$ report
report 2.1: 14 jobs ok
exit=0
$ mv /opt/jobs/bin/report /opt/jobs/newbin/report
$ report
report 2.1: 14 jobs ok
exit=0
$ hash -t report
/opt/jobs/newbin/report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;checkhash&lt;/code&gt; set, bash checks that a hashed path still exists before running it, searches &lt;code&gt;PATH&lt;/code&gt; again when it does not, and updates the table. In the wild this shows up after an upgrade moves a binary between &lt;code&gt;/usr/local/bin&lt;/code&gt; and &lt;code&gt;/usr/bin&lt;/code&gt;, or after swapping a pip-installed tool for the distro package, both of which the page describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six causes, one exit code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;command not found&lt;/code&gt; and its 127 cover more than a stale hash. The page reproduces the rest: a typo, a program installed outside &lt;code&gt;PATH&lt;/code&gt;, a script in the current directory run without &lt;code&gt;./&lt;/code&gt;, an alias a script cannot see, a found file whose shebang names a missing interpreter (a CRLF line ending does that), and the cron job whose &lt;code&gt;PATH&lt;/code&gt; is shorter than your terminal's.&lt;/p&gt;

&lt;p&gt;The cron one is worth reproducing, because it never fails while you are watching. Below, a demo tool in a scratch directory on my interactive &lt;code&gt;PATH&lt;/code&gt;, then the same call with cron's two-directory &lt;code&gt;PATH&lt;/code&gt;, which &lt;code&gt;env -i&lt;/code&gt; reproduces without waiting for the schedule. After that, the page's diagnosis script, &lt;code&gt;why-command-not-found.sh&lt;/code&gt;, run in the namespace with a home directory of its own, shown as &lt;code&gt;~&lt;/code&gt;, and the same tool in &lt;code&gt;~/.local/bin&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ nightly-sync
nightly-sync 0.4
exit=0
$ env -i PATH=/usr/bin:/bin bash -c 'nightly-sync'
bash: line 1: nightly-sync: command not found
exit=127

$ ./why-command-not-found.sh nightly-sync
✓ nightly-sync resolves to ~/.local/bin/nightly-sync
✗ cron's default PATH (/usr/bin:/bin) will NOT find it: use ~/.local/bin/nightly-sync in the crontab, or set PATH= at its top
  if your terminal still says 'not found' or 'No such file', its hash table is stale: run hash -r
exit=0
$ ./why-command-not-found.sh gti
✗ gti is not on PATH
  did you mean git? (/usr/bin/git)
exit=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It uses &lt;code&gt;type -P&lt;/code&gt;, which searches &lt;code&gt;PATH&lt;/code&gt; for files only and ignores aliases and functions, so it sees what any other script would see. It repeats the lookup with &lt;code&gt;PATH=/usr/bin:/bin&lt;/code&gt; to catch the cron case, reads the first line of the file for a carriage return, checks &lt;code&gt;./NAME&lt;/code&gt;, looks in the usual places installers put binaries without touching &lt;code&gt;PATH&lt;/code&gt; (&lt;code&gt;~/.local/bin&lt;/code&gt;, &lt;code&gt;~/.cargo/bin&lt;/code&gt;, &lt;code&gt;/usr/sbin&lt;/code&gt;, &lt;code&gt;/opt/*/bin&lt;/code&gt;), and tries every adjacent-letter swap of the name, which is how &lt;code&gt;gti&lt;/code&gt; became &lt;code&gt;git&lt;/code&gt;. It exits 1 for every cause it finds, so &lt;code&gt;./why-command-not-found.sh rsync || exit 1&lt;/code&gt; can gate a deploy script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order that separates them
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;type -a NAME&lt;/code&gt; for everything bash can see. &lt;code&gt;hash -t NAME&lt;/code&gt; when the message says &lt;code&gt;No such file or directory&lt;/code&gt; about a path. &lt;code&gt;echo "$PATH" | tr : '\n'&lt;/code&gt; when the answer is "nothing". And &lt;code&gt;env -i PATH=/usr/bin:/bin bash -c NAME&lt;/code&gt; before anything goes in a crontab.&lt;/p&gt;

&lt;p&gt;The stale hash is the one cause where everything you check says the tool is fine: &lt;code&gt;ls&lt;/code&gt; and &lt;code&gt;type -a&lt;/code&gt; both show it right where it should be. One &lt;code&gt;hash -r&lt;/code&gt;, or one new terminal, is the whole fix.&lt;/p&gt;




&lt;p&gt;All six causes reproduced, with the diagnosis script and what each check does: &lt;a href="https://bashsnippets.xyz/snippets/bash-command-not-found" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-command-not-found&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If 127 is all a log gives you, the &lt;a href="https://bashsnippets.xyz/tools/bash-exit-code-lookup" rel="noopener noreferrer"&gt;Bash Exit Code Lookup&lt;/a&gt; explains it next to 126, and the &lt;a href="https://bashsnippets.xyz/tools/path-debugger" rel="noopener noreferrer"&gt;Bash $PATH Debugger&lt;/a&gt; checks a pasted &lt;code&gt;PATH&lt;/code&gt; for missing directories and duplicates. 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>I Pointed rsync --delete at a Nearly Empty Directory. It Deleted 3,800 Files and Exited 0.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-pointed-rsync-delete-at-a-nearly-empty-directory-it-deleted-3800-files-and-exited-0-4ic7</link>
      <guid>https://dev.to/bashsnippets/i-pointed-rsync-delete-at-a-nearly-empty-directory-it-deleted-3800-files-and-exited-0-4ic7</guid>
      <description>&lt;p&gt;The rsync accident that ends backups is not an error. It is rsync doing exactly what it was told, successfully, to the wrong directory. I wanted to see how quiet that is, so I rebuilt it in a scratch directory on my own machine with rsync 3.5.0.&lt;/p&gt;

&lt;p&gt;The destination held 3,800 files, standing in for a nightly mirror. The source held one file, &lt;code&gt;index.nginx-debian.html&lt;/code&gt; — what Debian's nginx package leaves behind in &lt;code&gt;/var/www/html&lt;/code&gt; after you move a site's web root somewhere else and forget that the backup script still points at the old path. Then the mirror command anyone would have in that script: &lt;code&gt;rsync -a --delete src/ dst/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It exited 0. Afterwards the destination held one file: &lt;code&gt;index.nginx-debian.html&lt;/code&gt;. Three thousand eight hundred files gone, in a run that any cron wrapper, healthcheck ping or &lt;code&gt;✓ backup complete&lt;/code&gt; log line would have recorded as a success. Run that nightly and every green checkmark in the dashboard is a record of the deletion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit 0 doesn't mean what your monitoring thinks
&lt;/h2&gt;

&lt;p&gt;The bug is not in rsync. It is in what we assume its exit code promises. rsync reserves non-zero codes for failures on its own terms: an unreachable host, a protocol error, an I/O failure, source files vanishing mid-transfer (code 24), a partial transfer (code 23). Making a destination match a near-empty source is none of those. It is the requested operation, delivered without incident. A monitor that checks the exit code answers "did rsync finish?" and most of us have labelled that answer "is the data safe?" Those are different questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  rsync's sharp edges fail by succeeding
&lt;/h2&gt;

&lt;p&gt;Once you see that shape, you find it all over the command. A trailing slash on the source means "copy the contents"; no trailing slash means "copy the directory itself", which nests a second directory inside your destination and, on a &lt;code&gt;--delete&lt;/code&gt; mirror, changes what gets compared and therefore what gets removed. Both versions run clean. Drop &lt;code&gt;-a&lt;/code&gt; and rsync stops preserving permissions, symlinks and timestamps, and because its quick check decides "already transferred" by size and modification time, a destination full of transfer-time mtimes never matches again, so every run quietly becomes a full re-copy that still exits 0. Hand a comma-separated list to a single &lt;code&gt;--exclude&lt;/code&gt; and it is treated as one pattern with a comma in it, so it matches nothing and &lt;code&gt;node_modules&lt;/code&gt; rides along every night. None of these produce an error. Each completes a different operation from the one in your head.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag that shows the damage first
&lt;/h2&gt;

&lt;p&gt;The habit that prevents the whole class is previewing every destructive sync. Same scratch directories, same command, with &lt;code&gt;--dry-run&lt;/code&gt; and &lt;code&gt;-v&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ rsync -a --delete --dry-run -v src/ dst/
sending incremental file list
deleting photo-999.jpg
deleting photo-998.jpg
deleting photo-997.jpg
…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thousands of &lt;code&gt;deleting&lt;/code&gt; lines, and afterwards the destination still held all 3,800 files. A wall of deletions in a preview you are reading is impossible to miss. The same wall executed in a log nobody opens is a green checkmark.&lt;/p&gt;

&lt;p&gt;One detail the documentation does not shout: the &lt;code&gt;-v&lt;/code&gt; is not optional. I ran &lt;code&gt;--dry-run&lt;/code&gt; without it against a second scratch directory and it printed nothing at all and exited 0. A dry run you cannot see is not a preview. Use &lt;code&gt;-v&lt;/code&gt;, or &lt;code&gt;--itemize-changes&lt;/code&gt; if you want one line per file with a change code.&lt;/p&gt;

&lt;h2&gt;
  
  
  So the builder makes the preview the default
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/tools/rsync-command-builder" rel="noopener noreferrer"&gt;Rsync Command Builder&lt;/a&gt; assembles the command in a live preview while you toggle the real options: archive mode, verbose output, compression, resume for unreliable links (&lt;code&gt;--partial --progress&lt;/code&gt;), SSH transport, a bandwidth cap for syncs that share a link with people trying to use it, and exclude patterns. You type the excludes comma-separated and it emits one individually quoted &lt;code&gt;--exclude&lt;/code&gt; flag per pattern, so the shell never expands your glob and the comma mistake cannot happen.&lt;/p&gt;

&lt;p&gt;The opinions built into it are the point. Turn on &lt;code&gt;--delete&lt;/code&gt; without dry-run and a red warning appears: those deletions are permanent, preview them first. Turn dry-run on and it switches to a green note telling you to remove &lt;code&gt;--dry-run&lt;/code&gt; only after you have read the output. The Mirror preset ships with dry-run already enabled — mirroring is the one mode where you switch the safety off on purpose instead of remembering to switch it on. The whole thing runs in your browser; nothing you type leaves the page.&lt;/p&gt;

&lt;p&gt;The scratch run took a fraction of a second to delete 3,800 files and report success. The preview took the same fraction of a second to list every one of them. The only difference was a flag.&lt;/p&gt;

&lt;p&gt;Build the command with the guardrails on: &lt;a href="https://bashsnippets.xyz/tools/rsync-command-builder" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools/rsync-command-builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the script around it — cron scheduling, SSH keys, a hardened nightly job — the &lt;a href="https://bashsnippets.xyz/snippets/rsync-remote-backup" rel="noopener noreferrer"&gt;Rsync Remote Backup&lt;/a&gt; snippet pairs with this tool, &lt;a href="https://bashsnippets.xyz/snippets/automated-file-backup" rel="noopener noreferrer"&gt;Automated File Backup&lt;/a&gt; covers the local variant, 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>webdev</category>
      <category>tools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>My Machine Has No mail Command. That's Where Most Disk Alerts Die.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/my-machine-has-no-mail-command-thats-where-most-disk-alerts-die-mj</link>
      <guid>https://dev.to/bashsnippets/my-machine-has-no-mail-command-thats-where-most-disk-alerts-die-mj</guid>
      <description>&lt;p&gt;I recently found that the disk check on my own Linux machine had been printing &lt;code&gt;WARNING: Disk at 88%&lt;/code&gt; into nothing: cron's journal said &lt;code&gt;No MTA installed, discarding output&lt;/code&gt; beside every run. So I checked what it would take for that warning to reach me by email. &lt;code&gt;mail&lt;/code&gt;: not installed. &lt;code&gt;mailx&lt;/code&gt;: not installed. &lt;code&gt;sendmail&lt;/code&gt;: not installed. &lt;code&gt;msmtp&lt;/code&gt;: not installed.&lt;/p&gt;

&lt;p&gt;That is normal for a desktop install and common on minimal server images. It also means every monitoring script that ends in &lt;code&gt;| mail -s "…" you@example.com&lt;/code&gt; is a script whose last line fails. Run it by hand and you would at least see &lt;code&gt;command not found&lt;/code&gt;. Run it from cron and the error goes the same way as the output: cron tries to mail it to you, has no mail agent to do it with, and discards it. Two layers of silence, and the check itself is working perfectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection and delivery are different jobs
&lt;/h2&gt;

&lt;p&gt;The detection logic is the part everybody polishes: read the number, compare it, decide. Telling a person is a separate job, and a monitor that only appends to a log file or prints to stdout has not done it — it has relocated the problem. Somebody still has to poll that output, and that somebody is a person with better things to do at 2am. The fix is to push the news to the place you already check compulsively, which for most of us is an inbox.&lt;/p&gt;

&lt;p&gt;The core is small enough to memorize:&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;USAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; / | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR==2 {print $5}'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 80 &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;printf&lt;/span&gt; &lt;span class="s1"&gt;'Disk on %s hit %s%% at %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USAGE&lt;/span&gt;&lt;span class="s2"&gt;"&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;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"[ALERT] disk space on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; you@example.com
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the number, compare it, and when it is over the line, pipe a message into &lt;code&gt;mail&lt;/code&gt; with a subject that names the machine — because once the same check runs on several hosts, an alert that does not say which server is on fire is a puzzle, not an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alert you "sent" that never arrived
&lt;/h2&gt;

&lt;p&gt;Installing &lt;code&gt;mail&lt;/code&gt; fixes the first layer and exposes the next one. &lt;code&gt;mail&lt;/code&gt; exiting 0 does not mean your alert reached anyone. It means the message was handed to whatever mail transfer agent lives on the box; delivery is the MTA's problem, and the exit code you can observe stops at the handoff. On a server with no MTA configured for the outside world, that handoff often drops the message into a local mailbox under &lt;code&gt;/var/mail&lt;/code&gt; — a file on the same machine that no human reads. The script reports success, and the alert sits a few directories away from the log it was supposed to replace.&lt;/p&gt;

&lt;p&gt;Cron adds one more trap on top. Jobs run with a minimal &lt;code&gt;PATH&lt;/code&gt;, so a &lt;code&gt;mail&lt;/code&gt; that resolves in your login shell can be &lt;code&gt;command not found&lt;/code&gt; under cron. The test that proves anything is not running the script by hand and receiving an email. It is letting &lt;em&gt;cron&lt;/em&gt; run it and receiving an email, then reading the MTA's log to see what it actually did.&lt;/p&gt;

&lt;p&gt;Real delivery means giving the box a real path out. On Debian or Ubuntu, &lt;code&gt;mailutils&lt;/code&gt; pulls in an MTA, and the "Internet Site" option covers a machine that is allowed to send directly. Plenty are not — outbound mail from cloud IP ranges gets filtered or spam-binned constantly — so relay through a provider instead: &lt;code&gt;msmtp&lt;/code&gt; pointed at a Gmail account with an app password is a few lines of config, and &lt;code&gt;curl&lt;/code&gt; can speak SMTP to a relay on boxes where you would rather not install an MTA at all. Either turns "handed to the MTA" into "accepted by a server whose whole job is delivering".&lt;/p&gt;

&lt;h2&gt;
  
  
  The opposite failure: the alert that cries wolf
&lt;/h2&gt;

&lt;p&gt;Once delivery works you meet the inverse problem. A threshold does not trip once; it stays tripped. Disk at 81 % at nine o'clock is still 81 % at ten, and an hourly cron job will mail you the same fact twenty-four times a day until you fix the disk or stop reading, which comes first. An alert channel you have learned to skim is a silent failure with extra steps.&lt;/p&gt;

&lt;p&gt;The guard is a dedupe sentinel: on send, touch a marker file; before sending, check the marker's age with &lt;code&gt;find "$MARKER" -mmin -1440&lt;/code&gt;. I ran that logic three times on my machine. The first call sent. The second, a moment later, was suppressed. Then I backdated the marker 25 hours with &lt;code&gt;touch -d&lt;/code&gt; and the third call sent again. One email per incident per day; the condition is still measured every hour, only the nagging is suppressed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery is the whole difference
&lt;/h2&gt;

&lt;p&gt;The check on my machine had the right threshold and the right schedule-shaped intentions. What it lacked was any route from the machine to me, and nothing in its own output could say so, because its output had nowhere to go either. Before you trust an alert, send one on purpose, from cron, and watch it arrive.&lt;/p&gt;

&lt;p&gt;The full script — the alert body with the top disk consumers included, the msmtp config for a Gmail relay, the once-per-24-hours dedupe guard, and the cron traps that make alerts vanish — is here: &lt;a href="https://bashsnippets.xyz/snippets/bash-send-email-alert" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-send-email-alert&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An email alert is the last guard an unattended job needs, not the first: the &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Cron Wrapper Generator&lt;/a&gt; wires alert-on-failure in alongside the lock, timeout and retry it generates, and &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 full playbook for jobs nobody watches. 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>Bash Said '-eq: unary operator expected'. The Script Still Exited 0.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/bash-said-eq-unary-operator-expected-the-script-still-exited-0-2ah1</link>
      <guid>https://dev.to/bashsnippets/bash-said-eq-unary-operator-expected-the-script-still-exited-0-2ah1</guid>
      <description>&lt;p&gt;Four lines of bash, one empty variable, two tests of it. I wrote it on purpose, in a scratch directory, to capture the error for a guide. The output is not what I expected to capture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat queue-check.sh
#!/bin/bash
count=""
if [ $count -eq 0 ]; then echo "queue empty"; fi
if [[ $count -eq 0 ]]; then echo "queue empty ([[ ]])"; fi

$ bash queue-check.sh
queue-check.sh: line 3: [: -eq: unary operator expected
queue empty ([[ ]])
exit=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first test printed an error. The second printed an answer, and the answer is wrong: &lt;code&gt;count&lt;/code&gt; is not zero, it is empty. Then the script exited 0, so anything watching the exit code, a cron wrapper or a CI step, saw a clean run.&lt;/p&gt;

&lt;p&gt;That is three separate lies from one bug, and only one of them is on stderr. The part I keep coming back to: if I had only written the &lt;code&gt;[[ ]]&lt;/code&gt; line, the way most modern bash guides tell you to, there would have been no message at all. I would have trusted "queue empty".&lt;/p&gt;

&lt;h2&gt;
  
  
  The message names the check, not the cause
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;unary operator expected&lt;/code&gt; points at &lt;code&gt;-eq&lt;/code&gt;. The bug is the variable before it. &lt;code&gt;$count&lt;/code&gt; is unquoted, and an empty unquoted variable is removed by word splitting before &lt;code&gt;[&lt;/code&gt; ever runs. So &lt;code&gt;[&lt;/code&gt; received &lt;code&gt;-eq 0&lt;/code&gt; instead of &lt;code&gt;"" -eq 0&lt;/code&gt;, tried to read &lt;code&gt;-eq&lt;/code&gt; as a one-argument test like &lt;code&gt;-f&lt;/code&gt;, and gave up with exit status 2. Inside an &lt;code&gt;if&lt;/code&gt;, status 2 counts as "not true", so the branch was skipped and the script carried on. &lt;code&gt;[[ ]]&lt;/code&gt; does no word splitting, so it saw an empty operand, and in an arithmetic comparison an empty string evaluates to 0. True.&lt;/p&gt;

&lt;p&gt;The fix is to quote it, &lt;code&gt;[ "$count" -eq 0 ]&lt;/code&gt;, which turns the error into an honest &lt;code&gt;integer expected&lt;/code&gt;, or to give it a default with &lt;code&gt;"${count:-0}"&lt;/code&gt; when empty really does mean zero.&lt;/p&gt;

&lt;p&gt;That pattern, a message that names the check that failed rather than the reason, holds for almost every bash error I reproduced for the guide. Here is the syntax-error version, from a script missing one semicolon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ bash missing-then.sh
missing-then.sh: line 4: syntax error near unexpected token `fi'
missing-then.sh: line 4: `fi'
exit=2

$ shellcheck missing-then.sh

In missing-then.sh line 2:
if [ -f /etc/hostname ] then
                        ^-- SC1010 (warning): Use semicolon or linefeed before 'then' (or quote to make it literal).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;bash blames line 4. The mistake is on line 2, where &lt;code&gt;then&lt;/code&gt; without a &lt;code&gt;;&lt;/code&gt; before it became one more argument to &lt;code&gt;[&lt;/code&gt;. bash parses a whole &lt;code&gt;if ... fi&lt;/code&gt; block before running it, and it reports the first token that cannot fit, which is the &lt;code&gt;fi&lt;/code&gt; with no &lt;code&gt;then&lt;/code&gt; open. The line number is where bash gave up, not where you went wrong. ShellCheck pointed at the right line on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permission denied, after chmod +x
&lt;/h2&gt;

&lt;p&gt;The one that wastes the most time is &lt;code&gt;Permission denied&lt;/code&gt; when the execute bit is already set. I reproduced it on a tmpfs mounted &lt;code&gt;noexec&lt;/code&gt;, inside a throwaway mount namespace so nothing real was touched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ chmod +x backup.sh
$ ./backup.sh
bash: line 1: ./backup.sh: Permission denied
exit=126
$ findmnt -no OPTIONS --target backup.sh
rw,noexec,relatime,uid=1000,gid=1000,inode64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;chmod&lt;/code&gt; succeeded, the mode says executable, and the kernel still refused, because the filesystem forbids executing anything on it, root included. Exit 126 means "found it, could not run it". The other causes chmod cannot reach are a parent directory missing its &lt;code&gt;x&lt;/code&gt; bit and a file owned by someone else, and &lt;code&gt;namei -l&lt;/code&gt; on the full path shows both in one listing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the nine
&lt;/h2&gt;

&lt;p&gt;The guide covers nine messages, each reproduced on my machine with the output pasted as it came out. &lt;code&gt;command not found&lt;/code&gt; has six causes behind one exit code, 127, and &lt;code&gt;type -a&lt;/code&gt; plus a look at cron's two-directory &lt;code&gt;PATH&lt;/code&gt; separates them. &lt;code&gt;bad interpreter: No such file or directory&lt;/code&gt; complains about a file that plainly exists, because the shebang ends in a Windows carriage return. &lt;code&gt;No space left on device&lt;/code&gt; can fire on a filesystem &lt;code&gt;df -h&lt;/code&gt; reports as 0% used, when the inodes ran out instead of the blocks. &lt;code&gt;Connection refused&lt;/code&gt; can come from a service that is listening, on &lt;code&gt;127.0.0.1&lt;/code&gt; only. And &lt;code&gt;Argument list too long&lt;/code&gt; means &lt;code&gt;rm&lt;/code&gt; never started, so nothing was deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order I read any error in now
&lt;/h2&gt;

&lt;p&gt;The message first, word for word, with the variable parts removed. Then the exit code: 127 not found, 126 found but not executable, 2 usually a syntax or usage error, 128+N killed by signal N. Then &lt;code&gt;bash -x&lt;/code&gt;, which prints each command after expansion, so you see the &lt;code&gt;[ -eq 0 ]&lt;/code&gt; bash actually ran instead of the &lt;code&gt;[ $count -eq 0 ]&lt;/code&gt; you wrote. Then ShellCheck, which flags the unquoted &lt;code&gt;$count&lt;/code&gt; as SC2086 before the script runs at all.&lt;/p&gt;

&lt;p&gt;Then validate the value before you test it. I re-ran the same check under &lt;code&gt;set -euo pipefail&lt;/code&gt;, and strict mode did nothing: &lt;code&gt;count=""&lt;/code&gt; is set, so &lt;code&gt;set -u&lt;/code&gt; stays quiet, and &lt;code&gt;set -e&lt;/code&gt; ignores a failing command inside an &lt;code&gt;if&lt;/code&gt; condition by design. The script printed the same error and then &lt;code&gt;still running&lt;/code&gt;. What stopped it was one line that says what the variable must look like, &lt;code&gt;[[ $count =~ ^[0-9]+$ ]] || { echo "count is not a number: '$count'" &amp;gt;&amp;amp;2; exit 1; }&lt;/code&gt;, which exited 1 and printed &lt;code&gt;count is not a number: ''&lt;/code&gt;. A quoted variable makes the error honest. A validated one makes the script stop.&lt;/p&gt;




&lt;p&gt;Full guide with all nine messages, the one check that tells each one's causes apart, and links to the fix for every case: &lt;a href="https://bashsnippets.xyz/guides/bash-error-messages" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/bash-error-messages&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If an exit code is all you have, the &lt;a href="https://bashsnippets.xyz/tools/bash-exit-code-lookup" rel="noopener noreferrer"&gt;Bash Exit Code Lookup&lt;/a&gt; decodes it, and the &lt;a href="https://bashsnippets.xyz/tools/shellcheck-error-decoder" rel="noopener noreferrer"&gt;ShellCheck Error Decoder&lt;/a&gt; explains the common SC codes ShellCheck prints. 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>grep Found 5 Matches for a Hostname I Expected 3 Times. sed Would Have Rewritten All 5.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/grep-found-5-matches-for-a-hostname-i-expected-3-times-sed-would-have-rewritten-all-5-5ghp</link>
      <guid>https://dev.to/bashsnippets/grep-found-5-matches-for-a-hostname-i-expected-3-times-sed-would-have-rewritten-all-5-5ghp</guid>
      <description>&lt;p&gt;To see how a routine rename goes wrong, I built a small config tree on my own machine. &lt;code&gt;conf.d/app.conf&lt;/code&gt; points an upstream at &lt;code&gt;api.internal:8080&lt;/code&gt;. &lt;code&gt;conf.d/health.conf&lt;/code&gt; has a health-check URL on the same host. &lt;code&gt;samples/example.conf&lt;/code&gt; is a sample shipped as documentation and should never change. &lt;code&gt;conf.d/docs.conf&lt;/code&gt; sets &lt;code&gt;doc_host = apixinternal.example.com&lt;/code&gt;, a different host that happens to look similar. And &lt;code&gt;cache.bin&lt;/code&gt; is a binary file that contains the string. The job: move the live config from &lt;code&gt;api.internal&lt;/code&gt; to &lt;code&gt;api-v2.internal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The reflex is one line: &lt;code&gt;sed -i "s/api.internal/api-v2.internal/g" $(grep -rl "api.internal" myapp)&lt;/code&gt;. It exited 0. Afterwards the two live files were correct — and &lt;code&gt;docs.conf&lt;/code&gt; now read &lt;code&gt;doc_host = api-v2.internal.example.com&lt;/code&gt;, the sample had been rewritten, and the binary cache had been edited in place. Five files touched, two intended, and nothing in the output to say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two mechanisms, one bad afternoon
&lt;/h2&gt;

&lt;p&gt;The shallow one is regex. In &lt;code&gt;api.internal&lt;/code&gt; that dot is not punctuation; it is a wildcard matching any single character, in &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;sed&lt;/code&gt; alike. So the pattern does not mean "this hostname", it means "this substring, with anything in the middle, wherever it appears", which is how &lt;code&gt;apixinternal&lt;/code&gt; matched. When you mean a literal dot, escape it (&lt;code&gt;api\.internal&lt;/code&gt;); when you mean a literal string, &lt;code&gt;grep -F&lt;/code&gt; says so outright.&lt;/p&gt;

&lt;p&gt;The deep one is order. &lt;code&gt;sed -i&lt;/code&gt; writes over the original file the instant the pattern matches — no preview, no confirmation, no undo unless you supply one. Text processing on a live tree is a pipeline with a sequence: &lt;code&gt;find&lt;/code&gt; decides which files may be touched, &lt;code&gt;grep&lt;/code&gt; shows what will actually change, &lt;code&gt;sed&lt;/code&gt; or &lt;code&gt;awk&lt;/code&gt; makes the change, and a diff earns your trust afterwards. The one-liner started at step three and pointed it at everything grep could see.&lt;/p&gt;

&lt;h2&gt;
  
  
  grep: count the matches before you rewrite them
&lt;/h2&gt;

&lt;p&gt;The search that should come first is the same one the one-liner hid inside &lt;code&gt;$( )&lt;/code&gt;. Run on its own, &lt;code&gt;grep -rn "api.internal" myapp&lt;/code&gt; printed five hits: the two live lines, the sample, the &lt;code&gt;apixinternal&lt;/code&gt; line in &lt;code&gt;docs.conf&lt;/code&gt;, and &lt;code&gt;binary file matches&lt;/code&gt; for the cache. I expected three. That mismatch is the entire value of the step: the extra two lines are the damage, caught at read time instead of write time.&lt;/p&gt;

&lt;p&gt;With the dot escaped and the search limited to config files — &lt;code&gt;grep -rn 'api\.internal' myapp --include="*.conf"&lt;/code&gt; — the count dropped to three: the two live lines and the sample. Closer, and still one file too many.&lt;/p&gt;

&lt;h2&gt;
  
  
  find: print the blast radius
&lt;/h2&gt;

&lt;p&gt;Every bulk edit begins with a file list, and the default list, "everything under this directory, including whatever I forgot lives there", is the dangerous one. &lt;code&gt;find myapp -type f -name "*.conf" -not -path "*/samples/*"&lt;/code&gt; printed three candidates: &lt;code&gt;app.conf&lt;/code&gt;, &lt;code&gt;docs.conf&lt;/code&gt; and &lt;code&gt;health.conf&lt;/code&gt;. The &lt;code&gt;-not -path&lt;/code&gt; is the single flag that keeps documentation out of a rename. Read that output like a checklist; anything destructive should only ever receive filenames that appeared on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  sed: transform with an undo you did not have to build
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-i.bak&lt;/code&gt; edits in place and leaves a &lt;code&gt;.bak&lt;/code&gt; copy of every original: a full rollback for the price of four characters. It is also the portable spelling. GNU sed treats the backup suffix as optional, but BSD and macOS sed require one, so a bare &lt;code&gt;sed -i 's/old/new/'&lt;/code&gt; that works on Linux swallows your expression as the suffix on a Mac.&lt;/p&gt;

&lt;p&gt;Composed, on a fresh copy of the same tree:&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;# Scope with find, confirm with grep, transform with a per-file undo&lt;/span&gt;
find myapp &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.conf"&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/samples/*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s1"&gt;'api\.internal'&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; + &lt;span class="se"&gt;\&lt;/span&gt;
  | xargs &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt;.bak &lt;span class="s1"&gt;'s/api\.internal/api-v2.internal/g'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It edited &lt;code&gt;app.conf&lt;/code&gt; and &lt;code&gt;health.conf&lt;/code&gt; and left a &lt;code&gt;.bak&lt;/code&gt; beside each. &lt;code&gt;docs.conf&lt;/code&gt; was a candidate from &lt;code&gt;find&lt;/code&gt; but &lt;code&gt;grep -l&lt;/code&gt; with the escaped dot never selected it, so &lt;code&gt;apixinternal.example.com&lt;/code&gt; survived. The sample was never a candidate. The binary cache was never a candidate. Two files changed, the two I meant. (If the tree can hold filenames with spaces, &lt;code&gt;grep -lZ&lt;/code&gt; into &lt;code&gt;xargs -0&lt;/code&gt; closes the word-splitting gap.) Then diff one file against its &lt;code&gt;.bak&lt;/code&gt; before believing the run.&lt;/p&gt;

&lt;h2&gt;
  
  
  awk: when the question is a column, not a line
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;sed&lt;/code&gt; think in lines. The moment your question is about a field — total bytes served from column 10 of an access log, requests grouped by status code — that is &lt;code&gt;awk&lt;/code&gt;, which splits every line into &lt;code&gt;$1&lt;/code&gt; through &lt;code&gt;$NF&lt;/code&gt; and runs a small program per line. Its &lt;code&gt;END&lt;/code&gt; block fires once after the last line, which is how a million-line log becomes a single number. Chain all four stages and you get the command worth keeping for incidents: &lt;code&gt;find&lt;/code&gt; scopes to today's logs, &lt;code&gt;grep&lt;/code&gt; pulls the error lines, &lt;code&gt;awk&lt;/code&gt; strips the timestamp fields so identical errors collapse together, and &lt;code&gt;sort | uniq -c | sort -rn | head&lt;/code&gt; ranks them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The list worth printing
&lt;/h2&gt;

&lt;p&gt;The unscoped one-liner did nothing wrong by its own rules: pattern found, substitution made, exit 0, five times. The difference between five files and two was an explicit file list, an escaped dot, and one grep read before the write. Scope, confirm, transform, verify. In that order this work is boring, and boring is the goal.&lt;/p&gt;

&lt;p&gt;The full guide, with each stage's flags and the incident-triage pipeline in copy-paste form: &lt;a href="https://bashsnippets.xyz/guides/bash-text-processing" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/bash-text-processing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you would rather assemble the flags than memorize them, the &lt;a href="https://bashsnippets.xyz/tools/find-command-builder" rel="noopener noreferrer"&gt;find command builder&lt;/a&gt; and &lt;a href="https://bashsnippets.xyz/tools/grep-pattern-builder" rel="noopener noreferrer"&gt;grep pattern builder&lt;/a&gt; construct and explain the exact invocation before you run it. 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>tutorial</category>
    </item>
    <item>
      <title>find -mtime +30 Kept a File That Was 30.5 Days Old. I Checked With touch -d.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 08 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/find-mtime-30-kept-a-file-that-was-305-days-old-i-checked-with-touch-d-48h1</link>
      <guid>https://dev.to/bashsnippets/find-mtime-30-kept-a-file-that-was-305-days-old-i-checked-with-touch-d-48h1</guid>
      <description>&lt;p&gt;Nothing on my Linux machine deletes anything by age unless I tell it to. &lt;code&gt;~/.cache&lt;/code&gt; alone holds 47 GB right now — 13 GB of pip downloads, 11 GB of uv, 5 GB of browser cache — each one a program doing exactly its job, none of them with a retention policy. Application logs have the same shape: a writer that never stops and no reader. That is how a disk goes from fine to full without a single thing malfunctioning.&lt;/p&gt;

&lt;p&gt;The standard fix is an age-based cleanup with &lt;code&gt;find&lt;/code&gt;. It is also a command that deletes files, so before trusting it with anything real, I tested its three sharp edges on files I backdated with &lt;code&gt;touch -d&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a full disk lies to you
&lt;/h2&gt;

&lt;p&gt;The reason prevention beats diagnosis here is what a full disk looks like when it arrives. The moment the filesystem runs out of blocks, every process that tries to write gets &lt;code&gt;ENOSPC&lt;/code&gt; back from the kernel, and almost no application reports that as "the disk is full". You get a failed save in one program, a crashed build in another and a database complaint in a third, all worded differently, while the process that filled the disk goes quiet because it cannot write either. By the time symptoms appear they point everywhere except the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aging files out with find
&lt;/h2&gt;

&lt;p&gt;The tool has shipped with every Linux and macOS box for decades:&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;LOG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/var/log/myapp"&lt;/span&gt;
&lt;span class="nv"&gt;DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30

&lt;span class="c"&gt;# Preview pass — read this list before letting anything delete&lt;/span&gt;
find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_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="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.log"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DAYS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that, read the output, and when the list holds exactly what you expect, swap &lt;code&gt;-print&lt;/code&gt; for &lt;code&gt;-delete&lt;/code&gt; and run it again. The three runs below are why the preview is not optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge one: -mtime +30 does not mean 30 days
&lt;/h2&gt;

&lt;p&gt;I created four logs dated 29 days, exactly 30 days, 30 and a half days, and 31 days back. &lt;code&gt;find . -name "*.log" -mtime +30 -print&lt;/code&gt; printed one of them: the 31-day file. The 30.5-day file survived.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;find&lt;/code&gt; measures age in whole 24-hour periods and throws the fraction away, so 30.5 days old counts as 30, and the &lt;code&gt;+&lt;/code&gt; means strictly greater than. &lt;code&gt;-mtime +29&lt;/code&gt; printed three files: 30, 30.5 and 31 days. If your policy says "keep 30 days", &lt;code&gt;+29&lt;/code&gt; is the flag that enforces it. The off-by-one is invisible in a quick test and surfaces a month later as "why is that file still there".&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge two: -delete goes last, always
&lt;/h2&gt;

&lt;p&gt;On a copy of the same directory I put the predicates in the wrong order: &lt;code&gt;find . -delete -name "*.log"&lt;/code&gt;. Afterwards the directory had zero entries. Every log, the non-log &lt;code&gt;keep.txt&lt;/code&gt;, everything.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;find&lt;/code&gt; evaluates its expression strictly left to right, and &lt;code&gt;-delete&lt;/code&gt; is an action that runs and returns true. Put it first and it executes on every file &lt;code&gt;find&lt;/code&gt; walks, before &lt;code&gt;-name&lt;/code&gt; ever gets a vote. There is no warning and no prompt. The fix is order: tests first, &lt;code&gt;-delete&lt;/code&gt; at the very end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge three: never pipe filenames to rm
&lt;/h2&gt;

&lt;p&gt;The reflex is &lt;code&gt;find … | xargs rm&lt;/code&gt;. I made four files — &lt;code&gt;app v2.log&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;v2.log&lt;/code&gt; and &lt;code&gt;keep.txt&lt;/code&gt; — and ran &lt;code&gt;find . -name "app v2.log" | xargs rm&lt;/code&gt;. When it finished, &lt;code&gt;app&lt;/code&gt; and &lt;code&gt;v2.log&lt;/code&gt; were gone. &lt;code&gt;app v2.log&lt;/code&gt;, the one file the command was aimed at, was still there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;xargs&lt;/code&gt; split the filename on the space and handed &lt;code&gt;rm&lt;/code&gt; two arguments, both of which happened to exist. That is the worst possible outcome: the target survives, two innocent files die, and &lt;code&gt;rm&lt;/code&gt; reports nothing unusual. &lt;code&gt;-delete&lt;/code&gt; never leaves &lt;code&gt;find&lt;/code&gt; — no pipe, no word splitting — so it cannot do that. If you genuinely need another command, &lt;code&gt;-print0 | xargs -0&lt;/code&gt; keeps each name in one piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blast-radius controls
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;-type f&lt;/code&gt; keeps directories and symlinks out of the match. And the path should name one application's log directory — &lt;code&gt;/var/log/nginx&lt;/code&gt;, &lt;code&gt;/var/log/myapp&lt;/code&gt; — never &lt;code&gt;/var/log&lt;/code&gt; wholesale, which holds logs the OS still needs and files that must never be deleted by age. Retention for a database transaction log is a backup problem, not a &lt;code&gt;find&lt;/code&gt; problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then take yourself out of the loop
&lt;/h2&gt;

&lt;p&gt;The version that actually prevents a full disk is the one that runs without you: the path and the age limit in named variables, a crontab line running it weekly, and the cleanup's own output appended to a log with a timestamp, so every run leaves a record. Whatever grows without bound gets aged out on a schedule, long before it matters.&lt;/p&gt;

&lt;p&gt;Three runs on backdated files took less time than reading this post. Each one would have been an incident on a real directory.&lt;/p&gt;

&lt;p&gt;Full script with the multi-directory loop, the &lt;code&gt;.gz&lt;/code&gt; variant for rotated logs, a before/after disk-usage report and ready-made cron lines: &lt;a href="https://bashsnippets.xyz/snippets/delete-old-log-files" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/delete-old-log-files&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Deleting old logs is the recovery; hearing about a filling disk before it hits 100 % is the upgrade — the &lt;a href="https://bashsnippets.xyz/snippets/disk-space-warning" rel="noopener noreferrer"&gt;disk space warning script&lt;/a&gt; does that, and when the space thief is not a log at all, &lt;a href="https://bashsnippets.xyz/snippets/find-large-files-linux" rel="noopener noreferrer"&gt;find large files&lt;/a&gt; names it. 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>My Disk Check Prints WARNING at 88%. Cron Throws It Away Every Monday.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 06 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/my-disk-check-prints-warning-at-88-cron-throws-it-away-every-monday-k8h</link>
      <guid>https://dev.to/bashsnippets/my-disk-check-prints-warning-at-88-cron-throws-it-away-every-monday-k8h</guid>
      <description>&lt;p&gt;There is a small disk check in my home directory that I wrote months ago. It reads &lt;code&gt;/&lt;/code&gt; with &lt;code&gt;df&lt;/code&gt;, strips the percent sign, and prints &lt;code&gt;WARNING: Disk at N%&lt;/code&gt; once usage passes 80. A crontab line runs it on Mondays. Today I ran it by hand for the first time in a while, and it answered &lt;code&gt;WARNING: Disk at 88%&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then I went looking for where that warning goes when cron runs it, and the answer was nowhere. The log file sitting next to the script was last written on May 2, and its newest line says &lt;code&gt;OK: Disk at 30%&lt;/code&gt; — those were manual runs, because the crontab line never redirected anything into it. Cron's own journal filled in the rest. Last Monday it ran the script 54 times between 05:00 and 05:59, because the schedule reads &lt;code&gt;* 5 * * 1&lt;/code&gt;, every minute of hour five, where I meant once. And beside those runs, the same journal says &lt;code&gt;No MTA installed, discarding output&lt;/code&gt;. Every warning that check has produced under cron went into a mail system this machine does not have.&lt;/p&gt;

&lt;p&gt;Nothing about that is exotic. It is the most common way a monitoring script fails: it runs, it detects the condition, and the channel between it and a person does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  A full disk never introduces itself
&lt;/h2&gt;

&lt;p&gt;The reason that channel matters is what the failure looks like when it arrives. When a filesystem hits 100 %, nothing says "the disk is full." Every process that needs a write fails in its own vocabulary: the database complains about its own files, the app dies persisting a session, and every error points at the component that surfaced it rather than the cause underneath. One root cause, several convincing disguises.&lt;/p&gt;

&lt;p&gt;Logging is a write too, so the logs stop at the moment the disk fills, and the recorded evidence ends right before the incident begins. And ext4 holds back about 5 % of blocks for root, so a root shell keeps working on a disk where every non-root service is starving. The machine feels healthy to the person debugging it.&lt;/p&gt;

&lt;p&gt;There is a second trap on the way back down, and this box taught me that one too. On August 28 I deleted a 44.6 GB video file here. &lt;code&gt;du&lt;/code&gt; dropped by 44 GB; &lt;code&gt;df&lt;/code&gt; moved by 5. XFCE's thumbnailer daemon, &lt;code&gt;tumblerd&lt;/code&gt;, had the file open, and the kernel does not release blocks while any process still holds a descriptor on the deleted inode. &lt;code&gt;pkill -x tumblerd&lt;/code&gt; gave the space back instantly. Judge a cleanup by &lt;code&gt;df&lt;/code&gt;, never by &lt;code&gt;du&lt;/code&gt;: &lt;code&gt;du&lt;/code&gt; counts names that are gone, &lt;code&gt;df&lt;/code&gt; counts blocks that are actually free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check, done so a person sees it
&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;# 80% is a calendar item; 100% is an outage&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1 &amp;amp;&amp;amp; $5+0 &amp;gt;= 80 {print $0}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NR&amp;gt;1&lt;/code&gt; skips df's header row. &lt;code&gt;$5+0&lt;/code&gt; is the part worth stealing: the Use% column is a string like &lt;code&gt;82%&lt;/code&gt;, and awk's numeric coercion reads the leading digits and drops the rest, so adding zero turns it into 82 with no &lt;code&gt;cut&lt;/code&gt; or &lt;code&gt;sed&lt;/code&gt;. It prints only the filesystems over the line, which means its output &lt;em&gt;is&lt;/em&gt; the alert.&lt;/p&gt;

&lt;p&gt;That is also where mine broke, so check the last hop before you trust any of it. &lt;code&gt;| mail -s&lt;/code&gt; only works on a machine with a working mail agent; test it once by hand. If there is no mail agent, send the output to a webhook or at least to a log line with a timestamp. And read the schedule field twice: &lt;code&gt;0 5 * * 1&lt;/code&gt; is once a week, &lt;code&gt;* 5 * * 1&lt;/code&gt; is sixty times.&lt;/p&gt;

&lt;p&gt;That one-liner is the first of the 25 scripts in the guide this post is drawn from, and the shape repeats across all of them: a small check, run on cron, that turns a silent slow failure into a loud early one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other 24 aim the same idea at different failures
&lt;/h2&gt;

&lt;p&gt;The guide groups them into the five ways servers actually go down: disk growth, backup gaps, service crashes, network problems and security drift.&lt;/p&gt;

&lt;p&gt;The disk section continues from the warning. &lt;code&gt;du -ah | sort -rh | head -20&lt;/code&gt; names what grew, and a &lt;code&gt;find&lt;/code&gt;-based pruner clears the predictable offenders. One flag in it deserves a label: &lt;code&gt;-mtime +30&lt;/code&gt; does not mean "30 days or older" — the &lt;code&gt;+&lt;/code&gt; means strictly more than 30 twenty-four-hour periods, and misreading it changes what gets deleted.&lt;/p&gt;

&lt;p&gt;The backup section checks the thing a timestamp cannot. A dump that has been failing quietly since a credential rotation is not a backup, it is confidence with a date on it. The guide's mysqldump script checks that the output file is non-empty before it finishes, so a broken backup fails loudly on night one instead of on restore day.&lt;/p&gt;

&lt;p&gt;The service section caps undetected downtime: &lt;code&gt;systemctl is-active --quiet nginx || systemctl restart nginx&lt;/code&gt; on a five-minute cron means a crash costs five minutes. On one condition — log every restart. A watchdog that does not log will re-heal a memory leak every few hours and hide it by suppressing its only symptom.&lt;/p&gt;

&lt;p&gt;The SSL check reads the certificate the server is actually presenting over the wire, not the file on disk, which catches the renewed-but-never-reloaded case. And &lt;code&gt;ss -tlnp&lt;/code&gt; compared against a known baseline turns "unexpected listener on a public port" from a post-incident discovery into a line item.&lt;/p&gt;

&lt;p&gt;The closing section wires everything into &lt;code&gt;/etc/cron.d&lt;/code&gt; with staggered minutes and one shared log, and that log turns out to be the real product: after a week you know your normal. A deviation from a known baseline takes seconds to spot. My own disk check had a threshold and a schedule and no way to reach anyone, which is the gap the cron section exists to close.&lt;/p&gt;

&lt;p&gt;All 25 scripts, grouped by failure category, each with its core command and a link to the hardened version with thresholds, alerting and cron lines: &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-every-sysadmin-needs" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/bash-scripts-every-sysadmin-needs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once they are scheduled, the jobs themselves have to survive running unattended. &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron" rel="noopener noreferrer"&gt;Bash Scripts That Survive Cron&lt;/a&gt; covers the locks, timeouts and retries, and the &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Cron Wrapper Generator&lt;/a&gt; wraps those guards around any command you paste in. 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>tutorial</category>
    </item>
    <item>
      <title>sed 's/port/listen_port/g' Changed Four Lines. I Wanted One.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/sed-sportlistenportg-changed-four-lines-i-wanted-one-183e</link>
      <guid>https://dev.to/bashsnippets/sed-sportlistenportg-changed-four-lines-i-wanted-one-183e</guid>
      <description>&lt;p&gt;Renaming a config key is the job sed exists for, so I gave it the smallest honest test I could build on my own machine: a four-line YAML file and the rename most people type first. The file held &lt;code&gt;port: 8080&lt;/code&gt;, &lt;code&gt;support_email: ops@example.com&lt;/code&gt;, &lt;code&gt;transport: tcp&lt;/code&gt; and &lt;code&gt;report_dir: /var/reports&lt;/code&gt;. The command was &lt;code&gt;sed -i 's/port/listen_port/g' app.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;GNU sed 4.9 returned immediately, exit 0, no output. &lt;code&gt;-i&lt;/code&gt; edits in place and prints nothing, so there was nothing to read until I opened the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;listen_port: 8080
suplisten_port_email: ops@example.com
translisten_port: tcp
relisten_port_dir: /var/relisten_ports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line was meant to change. All four did. The email key is now a word no program will look up, the transport key is gone, and &lt;code&gt;report_dir&lt;/code&gt; points at &lt;code&gt;/var/relisten_ports&lt;/code&gt;, a directory that does not exist. Nothing failed. sed did what the command said, and the command said something different from what I meant. Across a service tree of forty config files that is not four bad lines; it is every file that happens to contain the letters, and you find out from whatever reads them next.&lt;/p&gt;

&lt;h2&gt;
  
  
  sed matches characters, not words
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;s/port/listen_port/g&lt;/code&gt; means: wherever the four characters p-o-r-t appear, replace them. Inside &lt;code&gt;support&lt;/code&gt;. Inside &lt;code&gt;transport&lt;/code&gt;. Inside &lt;code&gt;report&lt;/code&gt;, and twice on the &lt;code&gt;report_dir&lt;/code&gt; line because &lt;code&gt;reports&lt;/code&gt; carries them again. sed has no idea what a word is unless the pattern tells it.&lt;/p&gt;

&lt;p&gt;Three defaults stack into the failure, and none of them is a sed bug. An unanchored pattern that matches more than you pictured. &lt;code&gt;-i&lt;/code&gt;, so there is no output to review. And a glob like &lt;code&gt;*.yml&lt;/code&gt; or a &lt;code&gt;find | xargs&lt;/code&gt; that reaches more files than you have opened. Each one is a default you have to refuse on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: tell sed where the word ends
&lt;/h2&gt;

&lt;p&gt;GNU sed understands &lt;code&gt;\b&lt;/code&gt; as a word boundary, a zero-width match between a word character and a non-word character. On the same original file, &lt;code&gt;sed -i 's/\bport\b/listen_port/g' app.yml&lt;/code&gt; changed exactly one line, &lt;code&gt;listen_port: 8080&lt;/code&gt;, and left the other three alone. In &lt;code&gt;support&lt;/code&gt;, the &lt;code&gt;p&lt;/code&gt; of &lt;code&gt;port&lt;/code&gt; sits right after another word character, so &lt;code&gt;\b&lt;/code&gt; refuses to match there.&lt;/p&gt;

&lt;p&gt;Two details matter before you trust it. Underscore counts as a word character, so &lt;code&gt;\bport\b&lt;/code&gt; will not touch the &lt;code&gt;port&lt;/code&gt; inside &lt;code&gt;db_port&lt;/code&gt; either — usually what you want, occasionally not. And &lt;code&gt;\b&lt;/code&gt; is a GNU extension: BSD sed on macOS documents &lt;code&gt;[[:&amp;lt;:]]&lt;/code&gt; and &lt;code&gt;[[:&amp;gt;:]]&lt;/code&gt; for word start and end instead. A script that has to run on both platforms should not assume either spelling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: read the diff before anything is written
&lt;/h2&gt;

&lt;p&gt;The habit that matters more than any regex is refusing to let the first run be the in-place one. Drop &lt;code&gt;-i&lt;/code&gt; and pipe sed's output into &lt;code&gt;diff&lt;/code&gt; against the original: &lt;code&gt;sed 's/port/listen_port/g' app.yml | diff app.yml -&lt;/code&gt;. On my test file that printed all four lines as changed, &lt;code&gt;suplisten_port_email&lt;/code&gt; included, while the file on disk stayed intact. The whole disaster, on screen, costing nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;diff&lt;/code&gt; exits 1 when it finds differences, so the check works in a script as well as at a prompt. For more than one file, the shape that holds up is three steps. Build the file list with &lt;code&gt;grep -rl&lt;/code&gt; first, so sed only ever touches files that contain the pattern and everything else keeps its modification time — build caches and rsync care about that. Show a diff per file. Then require an explicit &lt;code&gt;--apply&lt;/code&gt; before a single byte is written. The default fails toward reading, not writing. If you forget the flag, the worst outcome is a diff you did not want to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three more places the same command bites
&lt;/h2&gt;

&lt;p&gt;The replacement side has its own special character. An unescaped &lt;code&gt;&amp;amp;&lt;/code&gt; in the replacement means "the entire match", so &lt;code&gt;s/CEO/Smith &amp;amp; Sons/&lt;/code&gt; turns &lt;code&gt;CEO&lt;/code&gt; into &lt;code&gt;Smith CEO Sons&lt;/code&gt;. Write &lt;code&gt;\&amp;amp;&lt;/code&gt; when you mean an ampersand.&lt;/p&gt;

&lt;p&gt;The delimiter is yours to choose. &lt;code&gt;s|/var/www/html|/srv/www|g&lt;/code&gt; is the same command as the version with every slash escaped, and it is the one you can still read under pressure. Pick a character that appears in neither side.&lt;/p&gt;

&lt;p&gt;When the pattern comes from a shell variable, it is still a regex. &lt;code&gt;OLD=staging.internal&lt;/code&gt; matches &lt;code&gt;stagingXinternal&lt;/code&gt; too, because the dot means any character, and a &lt;code&gt;|&lt;/code&gt; or &lt;code&gt;&amp;amp;&lt;/code&gt; inside the value breaks the command outright. For values you control, a delimiter the value cannot contain is enough. For arbitrary input — filenames, anything a user typed — escape the variable first or use a tool that does literal replacement.&lt;/p&gt;

&lt;p&gt;And the macOS trap that eventually costs every mixed team an afternoon: GNU &lt;code&gt;-i&lt;/code&gt; takes an optional suffix, BSD &lt;code&gt;-i&lt;/code&gt; requires one. On a Mac, &lt;code&gt;sed -i 's/old/new/g' file&lt;/code&gt; treats the script as the backup suffix and then tries to run the filename as the program. The BSD spelling is &lt;code&gt;sed -i '' 's/old/new/g' file&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where sed stops
&lt;/h2&gt;

&lt;p&gt;sed works one line at a time, so a plain &lt;code&gt;s///&lt;/code&gt; never matches across a newline. For multi-line surgery, &lt;code&gt;awk&lt;/code&gt; or &lt;code&gt;perl -0pe&lt;/code&gt; are the honest tools. And if the file is JSON or YAML with real structure, stop doing regex on it; a field-aware tool such as &lt;code&gt;jq&lt;/code&gt; parses instead of pattern-matching, which is the entire reason it exists.&lt;/p&gt;

&lt;p&gt;The rename I wanted needed two characters of anchoring and one pipe to &lt;code&gt;diff&lt;/code&gt;. The version without them exited 0 and rewrote four lines out of four.&lt;/p&gt;




&lt;p&gt;The full bulk-replace script — &lt;code&gt;grep -rl&lt;/code&gt; file list, per-file diff, &lt;code&gt;--apply&lt;/code&gt; gate, GNU/BSD &lt;code&gt;-i&lt;/code&gt; detection — with the line-by-line breakdown and the FAQ on delimiters and multi-line matches: &lt;a href="https://bashsnippets.xyz/snippets/bash-sed-find-replace" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-sed-find-replace&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the discovery half of the job, &lt;a href="https://bashsnippets.xyz/snippets/search-files-for-text-grep" rel="noopener noreferrer"&gt;searching files with grep&lt;/a&gt; covers finding the text before you replace it, and the &lt;a href="https://bashsnippets.xyz/tools/grep-pattern-builder" rel="noopener noreferrer"&gt;Grep Pattern Builder&lt;/a&gt; builds anchored patterns interactively. 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>SC2086 Is ShellCheck's Lowest-Severity Warning. It Let rm Delete Two Files I Never Named.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 29 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/sc2086-is-shellchecks-lowest-severity-warning-it-let-rm-delete-two-files-i-never-named-3jf9</link>
      <guid>https://dev.to/bashsnippets/sc2086-is-shellchecks-lowest-severity-warning-it-let-rm-delete-two-files-i-never-named-3jf9</guid>
      <description>&lt;p&gt;ShellCheck files SC2086 at &lt;strong&gt;info&lt;/strong&gt;, the lowest severity it shows by default, in the same tier as remarks about &lt;code&gt;echo&lt;/code&gt; flags. It fires on every unquoted &lt;code&gt;$var&lt;/code&gt;, so on a script that has never been linted it is often most of the output, and it is the rule people reach for first when they start a &lt;code&gt;.shellcheckrc&lt;/code&gt;. It reads like a style nag. I wanted to know what treating it like one actually costs, so I built the smallest case I could on my own machine: three files and a four-line script.&lt;/p&gt;

&lt;p&gt;The directory held &lt;code&gt;quarterly&lt;/code&gt;, &lt;code&gt;report.txt&lt;/code&gt; and &lt;code&gt;quarterly report.txt&lt;/code&gt;. The script set &lt;code&gt;report="quarterly report.txt"&lt;/code&gt; and ran &lt;code&gt;rm $report&lt;/code&gt;, with &lt;code&gt;set -euo pipefail&lt;/code&gt; at the top.&lt;/p&gt;

&lt;p&gt;It deleted &lt;code&gt;quarterly&lt;/code&gt;. It deleted &lt;code&gt;report.txt&lt;/code&gt;. It left &lt;code&gt;quarterly report.txt&lt;/code&gt;, the one file it was asked to remove, exactly where it was. And it exited 0, because as far as &lt;code&gt;rm&lt;/code&gt; could tell it had been handed two names and both existed. Strict mode had nothing to catch; nothing failed. In a real cleanup job that is a wrong-file deletion with a green exit code, and the first sign is the next job looking for a file that is no longer there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What bash does before rm ever runs
&lt;/h2&gt;

&lt;p&gt;The mechanism is that an unquoted &lt;code&gt;$report&lt;/code&gt; is not handed to &lt;code&gt;rm&lt;/code&gt; as one thing. Bash expands the variable, splits the result on whitespace, expands any &lt;code&gt;*&lt;/code&gt; or &lt;code&gt;?&lt;/code&gt; it finds against the current directory, and only then builds the argument list. &lt;code&gt;rm $report&lt;/code&gt; with a space in the value has become &lt;code&gt;rm quarterly report.txt&lt;/code&gt; — two arguments — by the time &lt;code&gt;rm&lt;/code&gt; wakes up. Double quotes switch both steps off: &lt;code&gt;rm "$report"&lt;/code&gt; is one argument, no globbing, done.&lt;/p&gt;

&lt;p&gt;Then the lint run on the same four lines, ShellCheck 0.11.0 and bash 5.3.9:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ shellcheck before.sh

In before.sh line 4:
rm $report
   ^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
rm "$report"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Info. The same severity ShellCheck gives a remark about &lt;code&gt;echo&lt;/code&gt; flags. There is no severity level for "deletes files you did not name and reports success," so this is where it lives, and that label is the reason it is the first rule people exclude.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the same bug hides
&lt;/h2&gt;

&lt;p&gt;The glob half is quieter. &lt;code&gt;pattern="*.log"&lt;/code&gt; followed by &lt;code&gt;echo Looking for $pattern&lt;/code&gt; prints the names of the log files in the directory instead of the pattern, because the star was expanded before &lt;code&gt;echo&lt;/code&gt; saw it. Hand that unquoted pattern to &lt;code&gt;find -name&lt;/code&gt; and it breaks the same way: one matching file and &lt;code&gt;find&lt;/code&gt; searches for that literal name, two and it dies with &lt;code&gt;paths must precede expression&lt;/code&gt;. ShellCheck flags it as SC2086 too; its separate code, SC2061, is for a glob typed straight into the command, &lt;code&gt;find . -name *.log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;[ ]&lt;/code&gt; the failure changes shape. &lt;code&gt;[&lt;/code&gt; is a command, so &lt;code&gt;[ $name = "root" ]&lt;/code&gt; with an empty &lt;code&gt;$name&lt;/code&gt; gives &lt;code&gt;test&lt;/code&gt; two arguments instead of three. It complains about a unary operator on stderr, the condition evaluates false, and the script keeps walking. The branch you were guarding is skipped without an exit. Inside &lt;code&gt;[[ ]]&lt;/code&gt; bash does not split at all, which is why ShellCheck stays quiet there and why "switch to double brackets" is a legitimate fix rather than a dodge.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;for f in $files&lt;/code&gt; — the one place where splitting is usually on purpose — ShellCheck 0.11.0 does not flag at all. It assumes you meant it. The loop still breaks on the first element containing a space. What you need is a list that can hold spaces, which in bash means an array, and an unquoted array expansion gets a different, louder code: SC2068, at error severity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest case for turning it off
&lt;/h2&gt;

&lt;p&gt;Here is the nuance a blanket disable throws away. There are two real situations where the split is the point: a flag string read from a config file — &lt;code&gt;opts="-r -n"&lt;/code&gt; then &lt;code&gt;grep $opts pattern .&lt;/code&gt; — and arguments passed through a thin wrapper. ShellCheck cannot know the split is intentional, so it warns. You have two honest exits. Make it an array, &lt;code&gt;opts=(-r -n)&lt;/code&gt; and &lt;code&gt;grep "${opts[@]}" pattern .&lt;/code&gt;, which ShellCheck accepts without comment. Or keep the string and put &lt;code&gt;# shellcheck disable=SC2086&lt;/code&gt; on the line above, with the reason after a second &lt;code&gt;#&lt;/code&gt;, so the next reader knows it was a decision and not a leftover.&lt;/p&gt;

&lt;p&gt;That directive covers one command. The repo-wide &lt;code&gt;.shellcheckrc&lt;/code&gt; covers every command you will ever write in that repo, including the &lt;code&gt;rm&lt;/code&gt;. That is the difference between disabling a check and disabling your own judgement.&lt;/p&gt;

&lt;p&gt;There is also the opposite move, which most people who disable the rule never find: &lt;code&gt;shellcheck --include=SC2086 script.sh&lt;/code&gt; reports this rule and nothing else, and still exits 1 when it finds one. That is how you enforce quoting as a CI gate on a repo that is still working through its other forty findings — the exact situation that makes people reach for &lt;code&gt;disable=&lt;/code&gt; in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs to filter by severity
&lt;/h2&gt;

&lt;p&gt;Quoted, &lt;code&gt;rm "$report"&lt;/code&gt; on the same directory deletes one file, the right one. ShellCheck's lowest severity holds the failure worth caring about most, and filtering by severity is exactly how it gets thrown away.&lt;/p&gt;

&lt;p&gt;The full write-up, with every run pasted from ShellCheck 0.11.0 — the &lt;code&gt;[ ]&lt;/code&gt; case, the for-loop that lints clean and still breaks, and the one-line, one-file and one-project ways to disable it: &lt;a href="https://bashsnippets.xyz/shellcheck/sc2086" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/shellcheck/sc2086&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the code on your screen is not SC2086, the &lt;a href="https://bashsnippets.xyz/tools/shellcheck-error-decoder" rel="noopener noreferrer"&gt;ShellCheck Error Decoder&lt;/a&gt; explains any SC code with a before/after fix, &lt;a href="https://bashsnippets.xyz/shellcheck/sc2046" rel="noopener noreferrer"&gt;SC2046&lt;/a&gt; is the same trap for &lt;code&gt;$(command)&lt;/code&gt; output, 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>shellcheck</category>
      <category>devops</category>
    </item>
    <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>
  </channel>
</rss>
