<?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>find -perm 777 Missed a World-Writable File in My Test Tree. -perm -o+w Didn't.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/find-perm-777-missed-a-world-writable-file-in-my-test-tree-perm-ow-didnt-5606</link>
      <guid>https://dev.to/bashsnippets/find-perm-777-missed-a-world-writable-file-in-my-test-tree-perm-ow-didnt-5606</guid>
      <description>&lt;p&gt;Most homegrown permission audits start with the search everybody remembers: &lt;code&gt;find /var/www -type f -perm 777&lt;/code&gt;. I built a three-file scratch tree on my own machine to see what that search actually covers. &lt;code&gt;index.html&lt;/code&gt; at a normal 644, &lt;code&gt;w666.txt&lt;/code&gt; at 666, and &lt;code&gt;w777.sh&lt;/code&gt; at 777 — the classic leftover from a &lt;code&gt;chmod 777&lt;/code&gt; thrown at an error to make it go away.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;find . -type f -perm 777&lt;/code&gt; found one file: &lt;code&gt;w777.sh&lt;/code&gt;. It did not find &lt;code&gt;w666.txt&lt;/code&gt;, which any account on the machine can write to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;find . -type f -perm -o+w&lt;/code&gt; found both.&lt;/p&gt;

&lt;p&gt;That one-character difference is the whole audit. Without a leading dash, &lt;code&gt;-perm&lt;/code&gt; matches files whose mode is &lt;em&gt;exactly&lt;/em&gt; the one you typed and nothing else; a world-writable file that is not also executable sits at 666 and is invisible to it. With the dash, &lt;code&gt;-perm&lt;/code&gt; switches to mask semantics: &lt;code&gt;-perm -o+w&lt;/code&gt;, or &lt;code&gt;-perm -002&lt;/code&gt;, matches any file with the other-write bit set, whatever the remaining bits say. Reach for the exact form when you are hunting your own &lt;code&gt;777&lt;/code&gt; leftovers specifically. Reach for the mask when the question is "what can other people write to", because that question does not care about the other eight bits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the other-write bit is the one that matters
&lt;/h2&gt;

&lt;p&gt;A Unix permission check sorts every process into owner, group or other, and a world-writable file hands write access to the last bucket: every account on the machine, including the ones you forgot exist. On a web server that includes the account the server itself runs as. A compromised plugin executing as &lt;code&gt;www-data&lt;/code&gt; needs no root and no stolen password — a world-writable file in the web root is one it can rewrite at will, whether that means defacing a page or planting a backdoor in a cache directory. There is no exploit chain in that story. There is an open write bit and something hostile enough to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that locks you out
&lt;/h2&gt;

&lt;p&gt;The tempting cleanup after an audit like that is one recursive command: &lt;code&gt;chmod -R 644&lt;/code&gt; across the whole tree. I ran it on a copy of the scratch tree.&lt;/p&gt;

&lt;p&gt;chmod complained before it finished — &lt;code&gt;cannot access 'site2/index.html': Permission denied&lt;/code&gt;, once per file — because it stripped the directory's execute bit first and then could not get back in to reach the files inside. Afterwards &lt;code&gt;ls site2&lt;/code&gt; still listed all three names, and &lt;code&gt;cat site2/index.html&lt;/code&gt; failed with &lt;code&gt;Permission denied&lt;/code&gt;. The files exist, have sane modes, and cannot be reached.&lt;/p&gt;

&lt;p&gt;On a directory, the execute bit does not mean "run this". It means "you may enter and traverse this". Take it away recursively and every directory becomes a wall; on a web server that shows up as a wave of 403s for content that is sitting right there on disk. Files and directories need different modes, which means two passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /var/www &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-perm&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt;+w 2&amp;gt;/dev/null    &lt;span class="c"&gt;# any world-writable file&lt;/span&gt;
find /var/www &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;644 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;     &lt;span class="c"&gt;# files: rw-r--r--&lt;/span&gt;
find /var/www &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;755 &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;     &lt;span class="c"&gt;# dirs keep x, or nobody gets in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line is the audit, the second and third are the repair. Files go to 644, directories to 755, and nothing is walled off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys go further, and ssh enforces it
&lt;/h2&gt;

&lt;p&gt;Secrets go down to 600, and for SSH private keys that is not advice — the tools enforce it. I generated a throwaway ed25519 key in the scratch directory, set it to 644, and asked &lt;code&gt;ssh-keygen -y&lt;/code&gt; to read it. It printed the &lt;code&gt;WARNING: UNPROTECTED PRIVATE KEY FILE!&lt;/code&gt; banner, &lt;code&gt;Permissions 0644 for 'testkey' are too open&lt;/code&gt;, then &lt;code&gt;This private key will be ignored&lt;/code&gt;, and exited 255. At 600 the same command loaded the key without comment.&lt;/p&gt;

&lt;p&gt;The ssh client makes the same check when it loads a key during a login. It ignores the key and carries on with whatever other methods it has, so what you see depends on what else was available: the banner followed by a password prompt, or the banner followed by &lt;code&gt;Permission denied&lt;/code&gt;. Either way the answer is in &lt;code&gt;ls -l&lt;/code&gt;, four characters wide, and the fix is &lt;code&gt;chmod 600&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the scan remember
&lt;/h2&gt;

&lt;p&gt;None of these three runs involved a malfunction. The exact-mode search did exactly what it says, chmod did exactly what it was told, and ssh refusing a readable private key &lt;em&gt;is&lt;/em&gt; the permission model working. What fails is memory: which &lt;code&gt;777&lt;/code&gt; did I throw at which error, and did I ever close it? A scan with the mask form of &lt;code&gt;-perm&lt;/code&gt; answers that in under a second, and a saved report makes the answer comparable from one week to the next.&lt;/p&gt;

&lt;p&gt;The full audit script — with the saved report file, the SUID scan for a deeper security pass, and the recommended-modes reference for files, directories, keys and private dirs — is at &lt;a href="https://bashsnippets.xyz/snippets/file-permissions-security" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/file-permissions-security&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your next move after fixing a key is generating one properly, the &lt;a href="https://bashsnippets.xyz/snippets/ssh-key-setup-script" rel="noopener noreferrer"&gt;SSH key setup script&lt;/a&gt; covers that end to end, and when you need a mode you do not have memorized, the &lt;a href="https://bashsnippets.xyz/tools/chmod-permissions-builder" rel="noopener noreferrer"&gt;Chmod Permissions Builder&lt;/a&gt; turns checkboxes into the octal value and the exact chmod 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>I Deleted a 48M Log to Free Space. The Next Write Still Hit 'No space left on device'.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-deleted-a-48m-log-to-free-space-the-next-write-still-hit-no-space-left-on-device-a26</link>
      <guid>https://dev.to/bashsnippets/i-deleted-a-48m-log-to-free-space-the-next-write-still-hit-no-space-left-on-device-a26</guid>
      <description>&lt;p&gt;A 64M filesystem, a worker that appends to a log and keeps it open the way a daemon does, and the reflex everyone has when a disk fills up: delete the log. I built it on a tmpfs mounted on &lt;code&gt;/srv&lt;/code&gt; inside &lt;code&gt;unshare -rm&lt;/code&gt;, a private mount namespace with no sudo, where I show up as root. The worker is a bash process that writes 48M to &lt;code&gt;worker.log&lt;/code&gt; through file descriptor 3 and then turns into &lt;code&gt;sleep&lt;/code&gt;, still holding the descriptor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ df -h /srv
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            64M   48M   16M  75% /srv
$ du -sh /srv/app
48M /srv/app
$ rm /srv/app/worker.log
$ du -sh /srv/app
0   /srv/app
$ df -h /srv
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            64M   48M   16M  75% /srv
$ head -c 32M /dev/zero &amp;gt; /srv/app/export.bin
head: error writing 'standard output': No space left on device
exit=1
$ du -sh /srv/app
16M /srv/app
$ df -h /srv
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            64M   64M     0 100% /srv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;du&lt;/code&gt; went to zero the moment the log was gone. &lt;code&gt;df&lt;/code&gt; did not move. Then a 32M write failed at 16M, and the filesystem reported itself 100% full while &lt;code&gt;du&lt;/code&gt; could only find the 16M half-file from the write that failed.&lt;/p&gt;

&lt;p&gt;Every check you would normally run after a cleanup says the cleanup worked. &lt;code&gt;ls&lt;/code&gt; shows no log. &lt;code&gt;du&lt;/code&gt; shows almost nothing. Only &lt;code&gt;df&lt;/code&gt;, the one number that matters to the next write, disagrees, and it does not say why.&lt;/p&gt;

&lt;h2&gt;
  
  
  rm removes a name, not the data
&lt;/h2&gt;

&lt;p&gt;A file is an inode, and a directory entry is a name pointing at it. &lt;code&gt;rm&lt;/code&gt; removes the name. The kernel frees the blocks only when the inode has no names left &lt;em&gt;and&lt;/em&gt; no process has it open. The worker still had descriptor 3 open, so 48M stayed allocated to a file nobody can see. &lt;code&gt;du&lt;/code&gt; walks names, so it stopped counting the moment the name went. &lt;code&gt;df&lt;/code&gt; asks the filesystem how many blocks are in use, so it kept counting.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lsof +L1&lt;/code&gt; lists open files with a link count below one, which is the definition of deleted-but-open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ lsof -w +L1 /srv
COMMAND    PID USER FD   TYPE DEVICE SIZE/OFF NLINK NODE NAME
sleep   608657 root 3w   REG   0,78 50331648     0    3 /srv/app/worker.log (deleted)
exit=0
$ ls -l /proc/$PID/fd | grep deleted
l-wx------ 1 root root 64 Oct  3 18:18 3 -&amp;gt; /srv/app/worker.log (deleted)
$ : &amp;gt; /proc/$PID/fd/3
$ df -h /srv
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            64M   16M   48M  25% /srv
$ ps -o pid,stat,cmd -p $PID
    PID STAT CMD
 608657 S    sleep 600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NLINK 0&lt;/code&gt;, &lt;code&gt;(deleted)&lt;/code&gt;, 50,331,648 bytes (48M exactly), held open for writing (&lt;code&gt;3w&lt;/code&gt;) by PID 608657. &lt;code&gt;/proc/PID/fd/3&lt;/code&gt; is a live handle on the deleted file, so &lt;code&gt;: &amp;gt; /proc/$PID/fd/3&lt;/code&gt; truncates it in place. &lt;code&gt;df&lt;/code&gt; dropped by 48M and the process kept running with its descriptor open, now pointing at an empty file. For a service, restarting it is the cleaner fix; truncation is for when you cannot, and it throws away whatever was in the file, so aim it at a runaway log and never at a database.&lt;/p&gt;

&lt;p&gt;One thing I did not expect: &lt;code&gt;lsof&lt;/code&gt; exited 0 here. On a second demo mount holding another deleted file, &lt;code&gt;lsof -w +L1 data&lt;/code&gt;, run from the parent directory, and &lt;code&gt;lsof -w +L1 /srv/demo/data&lt;/code&gt; printed the same row. The relative path exited 1, the absolute one 0. Same lsof 4.99.4, same file. Read its output, never its exit status.&lt;/p&gt;

&lt;h2&gt;
  
  
  The next time: truncate, and know how the log was opened
&lt;/h2&gt;

&lt;p&gt;The fix for next time is to empty a live log instead of deleting it: &lt;code&gt;: &amp;gt; /srv/app/worker.log&lt;/code&gt; keeps the inode and every open descriptor, and the space comes back at once. There is a catch that depends on how the writer opened the file, so I ran two writers side by side, one opened with &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; (&lt;code&gt;O_APPEND&lt;/code&gt;) and one with &lt;code&gt;&amp;gt;&lt;/code&gt;, each wrote 16M, I truncated both, and each wrote 1M more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ : &amp;gt; /srv/app/append.log; : &amp;gt; /srv/app/plain.log
$ ls -lsh /srv/app
total 2.0M
1.0M -rw-rw-r-- 1 root root 1.0M Oct  3 18:18 append.log
1.0M -rw-rw-r-- 1 root root  17M Oct  3 18:18 plain.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The append writer started again at the new end of the file: 1.0M on disk, 1.0M long. The other writer kept its own offset, 16M, and wrote its next megabyte there. &lt;code&gt;plain.log&lt;/code&gt; now claims to be 17M long with only 1.0M allocated, a sparse file whose first 16M is a hole. It costs no disk, but &lt;code&gt;ls -l&lt;/code&gt; reports 17M, anything that copies it naively gets 16M of zeros, and anyone sizing logs by &lt;code&gt;ls -l&lt;/code&gt; will chase a problem that is not there. That is why &lt;code&gt;logrotate&lt;/code&gt;'s &lt;code&gt;copytruncate&lt;/code&gt; suits append-mode logs (its man page warns it can still lose the lines written between the copy and the truncate), and why for anything else the right move is to make the program reopen its log, usually with &lt;code&gt;SIGHUP&lt;/code&gt; or a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script that does the looking
&lt;/h2&gt;

&lt;p&gt;On a real machine &lt;code&gt;lsof +L1&lt;/code&gt; prints dozens of rows, and a deleted file shared by many processes appears once per process. The page's &lt;code&gt;deleted-open-files.sh&lt;/code&gt; groups by inode, skips anything under 1M, sorts biggest first and totals it. On the same demo it printed &lt;code&gt;✗ 48M /srv/app/worker.log (deleted)&lt;/code&gt;, &lt;code&gt;held by: sleep[620003] fd 3&lt;/code&gt;, a total of 48M and the two ways to free it, and exited 1. After the truncate it printed &lt;code&gt;✓ no deleted-but-open files over 1.0M on /srv (as root)&lt;/code&gt; and exited 0, which makes it usable as a check in a disk alert.&lt;/p&gt;

&lt;p&gt;The 48M log was never gone. It had lost its name and kept its blocks, and &lt;code&gt;lsof +L1&lt;/code&gt; was the one command that could see both.&lt;/p&gt;




&lt;p&gt;lsof for deleted files, ports and busy mounts, with the grouping script: &lt;a href="https://bashsnippets.xyz/snippets/lsof-command-examples" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/lsof-command-examples&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To hear about a filling disk before a write fails, the &lt;a href="https://bashsnippets.xyz/snippets/disk-space-warning" rel="noopener noreferrer"&gt;disk space warning&lt;/a&gt; script alerts at a threshold you pick, and &lt;a href="https://bashsnippets.xyz/snippets/find-large-files-linux" rel="noopener noreferrer"&gt;Find Large Files on Linux&lt;/a&gt; covers what is still on the disk with a name. 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>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Tested a jq Alert Against a Down Status. It Printed 'no alert'.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-tested-a-jq-alert-against-a-down-status-it-printed-no-alert-mgk</link>
      <guid>https://dev.to/bashsnippets/i-tested-a-jq-alert-against-a-down-status-it-printed-no-alert-mgk</guid>
      <description>&lt;p&gt;The shape is everywhere in monitoring scripts: curl a status endpoint, pull one field out with jq, compare it, fire an alert on a match. I wrote the smallest honest version of it on my own machine, jq 1.8.1, against a response that said the service was down:&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;resp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"status":"down"}'&lt;/span&gt;
&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.status'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;                                                 &lt;span class="c"&gt;# ["down"]&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;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"down"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;ALERT&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no alert"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;   &lt;span class="c"&gt;# no alert&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service was down. The script said &lt;code&gt;no alert&lt;/code&gt;, and exited 0. The &lt;code&gt;echo "[$status]"&lt;/code&gt; line shows why: the variable holds &lt;code&gt;"down"&lt;/code&gt;, quote characters included. jq handed bash a six-character string, bash compared it byte for byte against the four characters in the test, and they never match. Put that in a five-minute cron job and it stays quiet through every outage it exists to report.&lt;/p&gt;

&lt;p&gt;Adding two characters, &lt;code&gt;jq -r&lt;/code&gt;, changed the output to &lt;code&gt;[down]&lt;/code&gt; and the script to &lt;code&gt;ALERT&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  jq speaks JSON, bash speaks bytes
&lt;/h2&gt;

&lt;p&gt;jq's default output format is JSON, and a JSON string includes its own quotes, so printing &lt;code&gt;"down"&lt;/code&gt; is jq being correct. Bash's &lt;code&gt;=&lt;/code&gt; has no idea that quotes might be markup; it compares the bytes it was given. &lt;code&gt;-r&lt;/code&gt; tells jq to emit the string's contents rather than its JSON representation, which is why nearly every value headed into a shell variable, a filename or a test wants it.&lt;/p&gt;

&lt;p&gt;That is the first of three ways a jq one-liner fails while looking right. The second: asking for a key the response does not have is not an error. On the same response, &lt;code&gt;jq -r '.region'&lt;/code&gt; printed &lt;code&gt;null&lt;/code&gt; and exited 0. In bash that is the literal four-character string &lt;code&gt;null&lt;/code&gt;, which passes &lt;code&gt;[ -n ]&lt;/code&gt; checks and walks straight into filenames and log lines. The &lt;code&gt;//&lt;/code&gt; operator is the guard: &lt;code&gt;jq -r '.region // "unknown"'&lt;/code&gt; printed &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The third is &lt;code&gt;select()&lt;/code&gt;, and here the folklore is half wrong. Forget the double quotes around a string inside the filter — &lt;code&gt;select(.name == redis)&lt;/code&gt; — and jq does not quietly match nothing. It refuses to compile, prints &lt;code&gt;redis/0 is not defined&lt;/code&gt;, and exits 3. That one is loud, and &lt;code&gt;set -e&lt;/code&gt; will catch it. The silent version is a value that is spelled right but does not exist: &lt;code&gt;select(.name == "nginx")&lt;/code&gt; against a list with no nginx in it printed nothing and exited 0. So did a case mismatch. Your script then treats that silence however it treats an empty string.&lt;/p&gt;

&lt;p&gt;Three traps, and two of them share one property: plausible output, zero exit code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the filter against the response you already have
&lt;/h2&gt;

&lt;p&gt;The usual way to debug all three is trial and error against the live API: tweak the filter, re-run the curl, squint, repeat, often against a rate-limited endpoint. The &lt;a href="https://bashsnippets.xyz/tools/jq-filter-builder" rel="noopener noreferrer"&gt;jq Filter Builder&lt;/a&gt; turns that around, because you already have the JSON.&lt;/p&gt;

&lt;p&gt;Paste a real response, or load one of the bundled samples, and it renders the parsed structure as a clickable tree. Click a field and the path is built for you, however deep it is nested. Arrays get the full treatment: switch on per-element iteration, attach a &lt;code&gt;select()&lt;/code&gt; whose field name comes from a dropdown filled with the keys that actually exist on the elements, so a misspelled key stops being possible, and project one field out of each match. The comparison value is written the way jq reads literals — &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt; and numbers bare, everything else double-quoted for you inside the single-quoted program. The quoting decision is made mechanically.&lt;/p&gt;

&lt;p&gt;Below that sit two copyable outputs: the bare filter for a script, and the full &lt;code&gt;curl -s … | jq&lt;/code&gt; command with your URL in place, &lt;code&gt;-r&lt;/code&gt; included when you ask for raw output. And a live preview, evaluated in the browser against the JSON you pasted, on every change. Leave &lt;code&gt;-r&lt;/code&gt; off and the quotes are right there in the preview. When the result is empty, the preview says why — &lt;code&gt;No elements matched (jq would output nothing)&lt;/code&gt;, &lt;code&gt;Path not found&lt;/code&gt;, or that a &lt;code&gt;// empty&lt;/code&gt; default is deliberately producing nothing — so empty output stops being ambiguous.&lt;/p&gt;

&lt;p&gt;It runs client-side: nothing is uploaded and no remote jq is invoked, so a response from a production system stays in your browser. Scope, honestly: it builds the extraction patterns that cover most API scripting — nested access, array indexing, iteration with &lt;code&gt;select()&lt;/code&gt; and projection, &lt;code&gt;//&lt;/code&gt; defaults, raw output. Reduce, string interpolation and arithmetic still belong to the manual.&lt;/p&gt;

&lt;p&gt;The four-line check at the top of this post could not have shipped from the builder. The quotes show up in the preview before the script exists.&lt;/p&gt;

&lt;p&gt;Build a filter against your own API response: &lt;a href="https://bashsnippets.xyz/tools/jq-filter-builder" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools/jq-filter-builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/snippets/bash-parse-json-jq" rel="noopener noreferrer"&gt;parse JSON with jq&lt;/a&gt; snippet covers these patterns in script form with the hardening around them, &lt;a href="https://bashsnippets.xyz/snippets/bash-curl-api-requests" rel="noopener noreferrer"&gt;curl for API requests&lt;/a&gt; is the other half of the same pipeline, 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>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>
  </channel>
</rss>
