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

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

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

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

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

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

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

&lt;p&gt;The fix is one extra clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Script: check-hosts.sh&lt;/span&gt;
&lt;span class="c"&gt;# Purpose: ping every host in a file, including a newline-less final line&lt;/span&gt;
&lt;span class="c"&gt;# Usage: ./check-hosts.sh hosts.txt&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;p&gt;This is the one that is most likely to make you question your sanity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# This looks correct. It is not.&lt;/span&gt;
&lt;span class="nv"&gt;fails&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="nb"&gt;cat &lt;/span&gt;hosts.txt | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; host&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="nt"&gt;-W2&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;fails++&lt;span class="o"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Total failures: &lt;/span&gt;&lt;span class="nv"&gt;$fails&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# Always prints 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;The fix is to redirect the file into the loop instead of piping into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;fails&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; host &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="nt"&gt;-W2&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;fails++&lt;span class="o"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; hosts.txt
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Total failures: &lt;/span&gt;&lt;span class="nv"&gt;$fails&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# Now correct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Never do this — iterates words, not lines&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;line &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;hosts.txt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$(cat hosts.txt)&lt;/code&gt; is command substitution. Bash captures the text output and word-splits it on IFS — spaces, tabs, newlines. For a file with one hostname per line and no spaces in the hostnames, this accidentally produces the right behavior. For any file with spaces — log lines, config values, paths with spaces, anything a non-developer might have generated — it splits lines into fragments and each fragment becomes a loop iteration.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;The fix is to stop treating command output as a filename list and let bash build the list from the filesystem directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Script: backup-exports.sh&lt;/span&gt;
&lt;span class="c"&gt;# Purpose: copy export files without losing ones with spaces in their names&lt;/span&gt;
&lt;span class="c"&gt;# Usage: ./backup-exports.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

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

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

&lt;/div&gt;



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

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

&lt;p&gt;The glob fixes the loop header. Quoting fixes the point of use. These are separate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.xlsx&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nv"&gt;$f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;    &lt;span class="c"&gt;# WRONG — $f re-splits here&lt;/span&gt;
  &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEST_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;  &lt;span class="c"&gt;# RIGHT — the quotes prevent the split&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;h2&gt;
  
  
  Ranges and counters: where the brace trap hides
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5

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

&lt;span class="c"&gt;# This works — C-style, evaluates variables at runtime&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;i &lt;span class="o"&gt;=&lt;/span&gt; 1&lt;span class="p"&gt;;&lt;/span&gt; i &amp;lt;&lt;span class="o"&gt;=&lt;/span&gt; n&lt;span class="p"&gt;;&lt;/span&gt; i++&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; of &lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Brace expansion happens before variable expansion in bash's order of operations. By the time &lt;code&gt;$n&lt;/code&gt; is replaced with its value, the brace expansion step has already passed and &lt;code&gt;{1..$n}&lt;/code&gt; is just a string. This is the kind of thing that fails silently in a test environment where the count is small and hardcoded, and causes weird output in production where it is a variable.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Arrays: the original bug wearing a different hat
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;servers&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"web-01"&lt;/span&gt; &lt;span class="s2"&gt;"db primary"&lt;/span&gt; &lt;span class="s2"&gt;"cache-02"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

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

&lt;span class="c"&gt;# RIGHT — quotes keep each element intact&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;servers&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ping &lt;span class="nt"&gt;-c1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"${servers[@]}"&lt;/code&gt; with the quotes and &lt;code&gt;[@]&lt;/code&gt; is the form that preserves each element as a single item regardless of what is in it. Without the quotes, bash word-splits the array expansion and &lt;code&gt;db primary&lt;/code&gt; becomes two separate loop iterations — neither of which is a real hostname. This is exactly the same word-splitting mechanism as the &lt;code&gt;$(ls)&lt;/code&gt; problem, just manifesting in arrays instead of command output.&lt;/p&gt;

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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; nullglob
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; /data/exports/&lt;span class="k"&gt;*&lt;/span&gt;.xlsx&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;shopt -s nullglob&lt;/code&gt;, if no &lt;code&gt;.xlsx&lt;/code&gt; files exist, the glob &lt;code&gt;*.xlsx&lt;/code&gt; does not expand — it stays as the literal string &lt;code&gt;*.xlsx&lt;/code&gt;. The loop runs once with &lt;code&gt;f&lt;/code&gt; set to the literal string &lt;code&gt;/data/exports/*.xlsx&lt;/code&gt;. Your script then tries to process a file with that exact path, which does not exist, and produces an error or silently does nothing depending on what you do with it.&lt;/p&gt;

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

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

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

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

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

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

</description>
      <category>bash</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>find . -delete Ran Before the Filter and Emptied the Whole Tree</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 17 Jun 2026 23:59:08 +0000</pubDate>
      <link>https://dev.to/bashsnippets/find-delete-ran-before-the-filter-and-emptied-the-whole-tree-3298</link>
      <guid>https://dev.to/bashsnippets/find-delete-ran-before-the-filter-and-emptied-the-whole-tree-3298</guid>
      <description>&lt;p&gt;I meant to delete the &lt;code&gt;.cache&lt;/code&gt; files under a data directory. The server had been running for two months and the cache layer had grown to about 14GB. The application team told me it was safe to purge it — they'd rebuilt the cache logic and the old files were just dead weight. I typed &lt;code&gt;find /data -delete -name "*.cache"&lt;/code&gt; because I was moving fast and I figured the order of arguments to &lt;code&gt;find&lt;/code&gt; did not matter. It does. &lt;code&gt;find&lt;/code&gt; evaluates its expression left to right, and &lt;code&gt;-delete&lt;/code&gt; is not a filter — it is an action. It fired on every path &lt;code&gt;find&lt;/code&gt; walked, starting at &lt;code&gt;/data&lt;/code&gt; itself, and &lt;code&gt;-name "*.cache"&lt;/code&gt; never got a chance to narrow anything. By the time I hit Ctrl-C the tree was roughly 80% gone.&lt;/p&gt;

&lt;p&gt;That was not a test server.&lt;/p&gt;

&lt;p&gt;The restore from backup took forty minutes. The application was down for those forty minutes. The postmortem was a forty-five minute conversation with people who did not particularly enjoy having it. I have run hundreds of &lt;code&gt;find&lt;/code&gt; commands since then and I verify the expression order before every single one that has any destructive action attached to it — not because I've forgotten the rule, but because the cost of forgetting it once is not recoverable with an apology.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the order is the program
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;find&lt;/code&gt; does not have a flag parser that groups tests and actions separately. It walks a directory tree and evaluates its arguments as a logical expression, left to right, short-circuiting on false. When you write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /data &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.cache"&lt;/span&gt; &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it evaluates &lt;code&gt;-name "*.cache"&lt;/code&gt; first on each path. If the name does not match, it short-circuits and &lt;code&gt;-delete&lt;/code&gt; never runs on that path. When you write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /data &lt;span class="nt"&gt;-delete&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.cache"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it evaluates &lt;code&gt;-delete&lt;/code&gt; first. &lt;code&gt;-delete&lt;/code&gt; always succeeds — it removes the path and returns true, which means the expression continues to &lt;code&gt;-name&lt;/code&gt;. The name check runs after the deletion, on a file that no longer exists, which is meaningless. The effect is that everything gets deleted and nothing is filtered.&lt;/p&gt;

&lt;p&gt;This is not a bug. It is exactly how the man page says &lt;code&gt;find&lt;/code&gt; works. It is just not how anyone instinctively reads a command the first few times they use it.&lt;/p&gt;

&lt;p&gt;The rule is: &lt;strong&gt;tests filter, actions act, and actions must come after the tests that are supposed to narrow them.&lt;/strong&gt; Write it in that order every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quoting trap, right behind the ordering one
&lt;/h2&gt;

&lt;p&gt;Here is the one that is even more subtle, because it causes the wrong behavior and produces no error at all:&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;# WRONG — the shell expands *.cache before find ever sees it&lt;/span&gt;
find /data &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.cache

&lt;span class="c"&gt;# RIGHT — quote the pattern so find does the matching&lt;/span&gt;
find /data &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.cache"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there happens to be a single &lt;code&gt;.cache&lt;/code&gt; file in your current working directory when you run this, the shell expands &lt;code&gt;*.cache&lt;/code&gt; to that one filename and passes it to &lt;code&gt;find -name&lt;/code&gt; as a literal string. &lt;code&gt;find&lt;/code&gt; then searches for files named exactly that, everywhere under &lt;code&gt;/data&lt;/code&gt;. It finds some, it finds none, but it is definitely not doing a wildcard search. If there are multiple &lt;code&gt;.cache&lt;/code&gt; files in your current directory, &lt;code&gt;find&lt;/code&gt; receives too many arguments for &lt;code&gt;-name&lt;/code&gt; and errors out with something confusing about paths needing to precede the expression.&lt;/p&gt;

&lt;p&gt;Either way you did not get what you intended, and if you added &lt;code&gt;-delete&lt;/code&gt;, you deleted the wrong things silently.&lt;/p&gt;

&lt;p&gt;Quoting the pattern is the fix. The quotes prevent the shell from expanding the glob so &lt;code&gt;find&lt;/code&gt; receives the literal &lt;code&gt;*.cache&lt;/code&gt; pattern and handles the wildcard itself, across the directory tree you pointed it at.&lt;/p&gt;

&lt;h2&gt;
  
  
  The age sign that everyone reverses at least once
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /var/log &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; +30   &lt;span class="c"&gt;# older than 30 days&lt;/span&gt;
find /var/log &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="nt"&gt;-1&lt;/span&gt;    &lt;span class="c"&gt;# modified within the last day&lt;/span&gt;
find /var/log &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; 30    &lt;span class="c"&gt;# exactly day 30 (almost never what you want)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;+30&lt;/code&gt; is older than thirty days. &lt;code&gt;-1&lt;/code&gt; is within the last day. A bare &lt;code&gt;30&lt;/code&gt; means precisely thirty days ago, which is almost never the thing you're trying to match. The sign convention is the opposite of what feels natural — you want files "older than" a threshold and the intuitive symbol for "bigger number" is &lt;code&gt;+&lt;/code&gt;, but a lot of people read &lt;code&gt;+30&lt;/code&gt; as "in the last thirty days" the first time they see it.&lt;/p&gt;

&lt;p&gt;I have reversed this twice in production. Once I kept the wrong logs. Once I deleted logs I needed for an audit the following week. Neither was catastrophic but both were embarrassing, and both happened under the kind of mild time pressure where double-checking the man page feels slower than it actually is. The builder I built for this labels the output as "older than" or "within the last" in plain English next to the value, which removes the one decision in a &lt;code&gt;find -delete&lt;/code&gt; job most likely to go wrong when you are already stressed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The -exec batching difference nobody explains
&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;# Runs the command once per file — slower, one PID per file&lt;/span&gt;
find /data &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.cache"&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;

&lt;span class="c"&gt;# Batches files into one invocation — faster, one rm for many files&lt;/span&gt;
find /data &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.cache"&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;\;&lt;/code&gt; runs the command once per file. &lt;code&gt;+&lt;/code&gt; collects as many paths as it can and passes them all to one invocation of the command, the same way &lt;code&gt;xargs&lt;/code&gt; does. For something like &lt;code&gt;rm&lt;/code&gt;, which accepts multiple arguments, the &lt;code&gt;+&lt;/code&gt; form is faster and produces less process overhead. For something like a custom script that must process exactly one file at a time, &lt;code&gt;\;&lt;/code&gt; is correct.&lt;/p&gt;

&lt;p&gt;Most resources either do not mention this difference or mention it once in a reference table. The practical consequence is real — on a directory with ten thousand files, &lt;code&gt;\;&lt;/code&gt; spawns ten thousand processes. The &lt;code&gt;+&lt;/code&gt; form spawns a handful. On a cleanup job that runs in cron, the difference shows up in CPU load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preview step I skipped the day I broke things
&lt;/h2&gt;

&lt;p&gt;The most reliable way to avoid the ordering and quoting mistakes is to build the command in two steps. First, run it with &lt;code&gt;-print&lt;/code&gt; instead of &lt;code&gt;-delete&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /data &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.cache"&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read every line of that output. Confirm the list is what you intended. Then, and only then, swap &lt;code&gt;-print&lt;/code&gt; for &lt;code&gt;-delete&lt;/code&gt;. This adds maybe thirty seconds to the workflow. It would have saved me forty minutes and a postmortem. I skipped it because I was confident. Confidence is not a useful substitute for verification when the operation is irreversible.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/tools/find-command-builder" rel="noopener noreferrer"&gt;find command builder&lt;/a&gt; enforces this by showing a warning whenever you select &lt;code&gt;-delete&lt;/code&gt; or &lt;code&gt;-exec&lt;/code&gt; as the action, and offering to generate the &lt;code&gt;-print&lt;/code&gt; version of the command first. It is the kind of nudge I would have appreciated having that day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the builder actually does
&lt;/h2&gt;

&lt;p&gt;It assembles the expression in the legally correct order — tests first, action last — so you cannot accidentally replicate the mistake I made. You set the starting path, add tests in whatever order feels natural to you (name, type, age, size, exclude path), and pick an action. The output command always has the tests before the action, regardless of the order you clicked things in.&lt;/p&gt;

&lt;p&gt;Every active flag gets a plain-English description inline. &lt;code&gt;-mtime +30&lt;/code&gt; reads as "modified more than 30 days ago." &lt;code&gt;-name "*.cache"&lt;/code&gt; reads as "name matches the glob &lt;code&gt;*.cache&lt;/code&gt;, quoted so find handles the wildcard." The &lt;code&gt;-exec {} +&lt;/code&gt; form is the default when you pick &lt;code&gt;-exec&lt;/code&gt;, with a note explaining why it is faster.&lt;/p&gt;

&lt;p&gt;The whole point is to get from "I need to find and delete files matching these conditions" to a verified, copy-paste command without the thirty-second loop of man-page reading and second-guessing that I used to do and, on one bad morning, skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I upgraded while I was at it
&lt;/h2&gt;

&lt;p&gt;The same discipline applies to the other tools. The rsync command builder now has presets for the three setups people build most often — local backup, push to remote, mirror — because those cover maybe ninety percent of rsync jobs and getting the flags right from scratch every time is where the dangerous ones like &lt;code&gt;--delete&lt;/code&gt; get misapplied. The cron builder previews the next five run times after you build an expression, because a cron job I once deployed ran at 3am UTC instead of 3am local time and I did not notice until it fired on the wrong schedule for a week. You can also paste an existing crontab line and get the human-readable schedule back. The chmod builder now accepts an octal you paste in and sets the checkboxes — two-directional, because reading a file's permissions and understanding what they mean is just as common a task as setting them from scratch.&lt;/p&gt;

&lt;p&gt;These are not features I planned in advance. They are all things I needed at 2am and did not have. That is the pattern most of this site runs on.&lt;/p&gt;

&lt;p&gt;Build a &lt;code&gt;find&lt;/code&gt; command with tests ordered before actions and every flag explained: &lt;a href="https://bashsnippets.xyz/tools/find-command-builder" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools/find-command-builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are scoping files before searching or transforming them, the whole pipeline is documented in &lt;a href="https://bashsnippets.xyz/guides/bash-text-processing" rel="noopener noreferrer"&gt;Bash Text Processing: find, grep, sed, and awk&lt;/a&gt;. The rest of the free tools are at &lt;a href="https://bashsnippets.xyz/tools" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>A for Loop Skipped 23 Files and Called It a Successful Backup</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Sun, 14 Jun 2026 17:26:03 +0000</pubDate>
      <link>https://dev.to/bashsnippets/a-for-loop-skipped-23-files-and-called-it-a-successful-backup-2j3f</link>
      <guid>https://dev.to/bashsnippets/a-for-loop-skipped-23-files-and-called-it-a-successful-backup-2j3f</guid>
      <description>&lt;p&gt;The backup ran every night at 2am and emailed me a green "847 files archived" summary. I'd built it, tested it against my own home directory where every file was named like &lt;code&gt;report_2024.csv&lt;/code&gt;, watched it sail through, and shipped it. For weeks the summary said everything was fine.&lt;/p&gt;

&lt;p&gt;Then a coworker asked me to restore a file. &lt;code&gt;Q3 forecast.xlsx&lt;/code&gt;. It wasn't in the archive. Neither was &lt;code&gt;Annual Review FINAL.pdf&lt;/code&gt;, or &lt;code&gt;meeting notes (draft).md&lt;/code&gt;, or any of the other 23 files someone had named the way normal humans name files — with spaces in them. The backup had been quietly skipping the most important files on the share for a week, and the nightly email had been telling me the whole time that nothing was wrong.&lt;/p&gt;

&lt;p&gt;Here's the line that did 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="k"&gt;for &lt;/span&gt;file &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Processing &lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is word splitting, and it's invisible until the day it isn't. An unquoted &lt;code&gt;$(ls ...)&lt;/code&gt; splits its output on every space, so &lt;code&gt;Q3 forecast.xlsx&lt;/code&gt; doesn't arrive as one filename — it arrives as two iterations, &lt;code&gt;Q3&lt;/code&gt; and &lt;code&gt;forecast.xlsx&lt;/code&gt;. Neither one exists on disk. The loop tries both, finds nothing, shrugs, and moves on without a single error. The script "succeeds" because from its point of view it did exactly what it was told.&lt;/p&gt;

&lt;p&gt;I've now got two habits burned in, and I don't write a loop without both. The first: glob the directory, never parse &lt;code&gt;ls&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;file &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;   &lt;span class="c"&gt;# the glob yields a literal '*' on an empty dir&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Processing &lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"$DIR"/*&lt;/code&gt; hands you each real path as a single unit. No subshell, no string to re-split, no &lt;code&gt;ls&lt;/code&gt; output to misread. The &lt;code&gt;[[ -e "$file" ]] || continue&lt;/code&gt; guard covers the one quirk of globbing: when a directory is empty, &lt;code&gt;*&lt;/code&gt; expands to the literal character &lt;code&gt;*&lt;/code&gt;, and without the guard you'd try to process a file named &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second habit: quote every expansion, every time. &lt;code&gt;"$file"&lt;/code&gt;, never &lt;code&gt;$file&lt;/code&gt;. The day a filename has a space in it, the unquoted version splits into two arguments and your command operates on paths that were never there.&lt;/p&gt;

&lt;p&gt;Arrays follow the exact same rule, and the distinction that matters is &lt;code&gt;[@]&lt;/code&gt; versus &lt;code&gt;[*]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;servers&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"web-01"&lt;/span&gt; &lt;span class="s2"&gt;"db-prod 02"&lt;/span&gt; &lt;span class="s2"&gt;"cache-03"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;host &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;servers&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Connecting to &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# three clean iterations, the space survives&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"${servers[@]}"&lt;/code&gt; in double quotes gives you one word per element — &lt;code&gt;db-prod 02&lt;/code&gt; stays whole. &lt;code&gt;"${servers[*]}"&lt;/code&gt; joins everything into a single string and is almost never what you want in a loop. Drop the quotes on either and you're back to the bug that ate my backup.&lt;/p&gt;

&lt;p&gt;When you genuinely need a counter — walking &lt;code&gt;app.log.1&lt;/code&gt; through &lt;code&gt;app.log.9&lt;/code&gt;, numbering batches, counting retries — use the C-style form. Do not reach for &lt;code&gt;for i in {1..$n}&lt;/code&gt;; brace expansion runs &lt;em&gt;before&lt;/em&gt; variable expansion, so &lt;code&gt;$n&lt;/code&gt; never gets substituted and you loop once over the literal text &lt;code&gt;{1..$n}&lt;/code&gt;. Ask me how I know.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;((&lt;/span&gt;i &lt;span class="o"&gt;=&lt;/span&gt; 1&lt;span class="p"&gt;;&lt;/span&gt; i &amp;lt;&lt;span class="o"&gt;=&lt;/span&gt; MAX_ROTATED&lt;span class="p"&gt;;&lt;/span&gt; i++&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;log&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;/app.log.&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$log&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Scanning &lt;/span&gt;&lt;span class="nv"&gt;$log&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And reading a file line by line — &lt;code&gt;for line in $(cat file)&lt;/code&gt; is wrong in two directions at once. It splits on whitespace instead of newlines, so a line with spaces becomes several iterations and blank lines vanish, and it globs, so a line containing &lt;code&gt;*&lt;/code&gt; expands to filenames in your current directory. The form that survives real files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Line: &lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IFS=&lt;/code&gt; stops bash from trimming leading and trailing whitespace. &lt;code&gt;-r&lt;/code&gt; stops it from eating backslashes. One line per iteration, exactly as written.&lt;/p&gt;

&lt;p&gt;The production script I keep on the site ties all of this together — globs the directory, quotes every use, counts successes and failures separately, and exits non-zero if anything failed. That last part is the one people skip, and it's the one that matters: a loop that processes files but always exits 0 is how you end up with a nightly email that says 847 when the real number is 824.&lt;/p&gt;

&lt;p&gt;The cost of my mistake was a week of bad backups and the specific discomfort of a coworker finding the gap before my own tooling did. I'm not interested in repeating that, so the quoting is muscle memory now. Yours might as well be too.&lt;/p&gt;

&lt;p&gt;Full script with all four loop forms — files, arrays, counters, and line-by-line reads — production-ready and ShellCheck-clean: &lt;a href="https://bashsnippets.xyz/snippets/bash-for-loop-examples" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/bash-for-loop-examples&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your loops are the thing crashing scripts, the &lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling" rel="noopener noreferrer"&gt;Bash Error Handling&lt;/a&gt; snippet pairs with this one, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>The Pipeline Was Green for Three Weeks. It Had Been Shipping a Build That Never Compiled.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Fri, 12 Jun 2026 16:27:11 +0000</pubDate>
      <link>https://dev.to/bashsnippets/the-pipeline-was-green-for-three-weeks-it-had-been-shipping-a-build-that-never-compiled-3k91</link>
      <guid>https://dev.to/bashsnippets/the-pipeline-was-green-for-three-weeks-it-had-been-shipping-a-build-that-never-compiled-3k91</guid>
      <description>&lt;p&gt;For three weeks a deployment pipeline reported every step green and shipped a build that had failed to compile on every single run. The build step ended in &lt;code&gt;npm run build | tee build.log&lt;/code&gt; so the output could be archived. That pipe is the whole story: bash returns the exit status of the &lt;em&gt;last&lt;/em&gt; command in a pipeline, which was &lt;code&gt;tee&lt;/code&gt;, and &lt;code&gt;tee&lt;/code&gt; always succeeds at copying text. The compiler's non-zero exit got thrown away the instant the pipe handed off. The error was sitting right there in &lt;code&gt;build.log&lt;/code&gt;. GitHub Actions saw exit code 0, painted the step green, and deployed the broken artifact. Nobody read the log, because the checkmark said there was nothing to read.&lt;/p&gt;

&lt;p&gt;That's the defining property of bash in CI, and it's why I treat pipeline scripts differently from anything I run in a terminal: &lt;strong&gt;a silent failure can present as success.&lt;/strong&gt; On a server you watch a command fail in front of you. In a pipeline, a swallowed exit code produces a green checkmark over broken code, and the gap between "the logs show an error" and "the pipeline reports failure" is exactly where outages are born. I wrote the full guide because I've now been burned by every variation of this, and there's a consistent set of habits that close the gap.&lt;/p&gt;

&lt;p&gt;There are four failure modes that are specific to CI and barely ever bite you at an interactive prompt. &lt;strong&gt;Exit codes swallowed by a pipe&lt;/strong&gt; — the story above, any &lt;code&gt;command | tee&lt;/code&gt;, &lt;code&gt;command | grep&lt;/code&gt;, &lt;code&gt;command | sort&lt;/code&gt;. &lt;strong&gt;Shell provisioning differences&lt;/strong&gt; — &lt;code&gt;ubuntu-latest&lt;/code&gt; gives you bash 5.x, &lt;code&gt;macos-latest&lt;/code&gt; gives you bash 3.2 from 2007, and a script using associative arrays or &lt;code&gt;${var,,}&lt;/code&gt; passes on one runner and throws a syntax error on the other in the same workflow. &lt;strong&gt;Environment variable gaps&lt;/strong&gt; — CI sets variables you don't control and omits ones you assume exist, and without &lt;code&gt;set -u&lt;/code&gt; a missing &lt;code&gt;$DEPLOY_TARGET&lt;/code&gt; becomes an empty string and does something quietly wrong. &lt;strong&gt;Interactive-shell assumptions&lt;/strong&gt; — CI runs a non-interactive, non-login shell that never sources your &lt;code&gt;.bashrc&lt;/code&gt;, so a command that works when you type it dies with &lt;code&gt;command not found&lt;/code&gt; because the thing that defined it was never loaded.&lt;/p&gt;

&lt;p&gt;The header that closes most of these is short, and every line earns its place:&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;#!/usr/bin/env 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;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\n\t&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set -e&lt;/code&gt; exits the moment any command fails, so the step exits non-zero and the workflow actually registers a failure. &lt;code&gt;set -u&lt;/code&gt; treats an unset variable as an error, so a typo'd &lt;code&gt;$DPLOY_TARGET&lt;/code&gt; dies immediately instead of expanding to nothing and corrupting a path. &lt;code&gt;set -o pipefail&lt;/code&gt; makes a pipeline return the first non-zero exit among its commands rather than only the last — that one flag is the direct fix for the &lt;code&gt;| tee&lt;/code&gt; bug that ran green for three weeks.&lt;/p&gt;

&lt;p&gt;Secrets deserve their own paragraph because CI hands you &lt;code&gt;env:&lt;/code&gt; values and &lt;code&gt;secrets:&lt;/code&gt; values identically — the shell can't tell them apart, the only difference is that Actions masks the secret's literal string in the log. The trap is that the moment you transform a secret (base64-decode it, slice it, interpolate it), the transformed value no longer matches the mask and prints in clear text. Validate required values up front with &lt;code&gt;${VAR:?}&lt;/code&gt; so a missing secret fails at startup with a clear message instead of on line 47 with a cryptic &lt;code&gt;permission denied&lt;/code&gt;, and be very careful with &lt;code&gt;set -x&lt;/code&gt; in any step that touches a secret.&lt;/p&gt;

&lt;p&gt;The pipe-exit-code problem is worth one concrete tool beyond &lt;code&gt;pipefail&lt;/code&gt;: &lt;code&gt;PIPESTATUS&lt;/code&gt; is an array holding the exit code of every command in the last pipeline, read immediately after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build | &lt;span class="nb"&gt;tee &lt;/span&gt;build.log
&lt;span class="nv"&gt;build_rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIPESTATUS&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# npm's code, not tee's&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$build_rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"build failed: &lt;/span&gt;&lt;span class="nv"&gt;$build_rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$build_rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pipefail&lt;/code&gt; has one well-known false positive — &lt;code&gt;grep&lt;/code&gt; returns 1 when it finds no matches, which is often fine, and under &lt;code&gt;pipefail&lt;/code&gt; plus &lt;code&gt;set -e&lt;/code&gt; that aborts the script. Absorb it deliberately with &lt;code&gt;|| true&lt;/code&gt; only where a non-match is genuinely acceptable, and nowhere else, because blanketing every command in &lt;code&gt;|| true&lt;/code&gt; just reinvents the silent-success problem you're trying to kill.&lt;/p&gt;

&lt;p&gt;Docker has its own landmine: every entrypoint script must end with &lt;code&gt;exec "$@"&lt;/code&gt;. Without it, your script stays PID 1 and your app runs as a child, so when the orchestrator sends SIGTERM on &lt;code&gt;docker stop&lt;/code&gt; or a rolling deploy, the signal hits the &lt;em&gt;script&lt;/em&gt;, which doesn't forward it, and after the grace period the orchestrator escalates to SIGKILL — abrupt termination, dropped connections, lost in-flight work. &lt;code&gt;exec "$@"&lt;/code&gt; replaces the shell with your app so it &lt;em&gt;becomes&lt;/em&gt; PID 1 and receives signals directly. The guide pairs this with a &lt;code&gt;wait_for&lt;/code&gt; dependency-check pattern and a trap, and the &lt;a href="https://bashsnippets.xyz/tools/bash-trap-builder" rel="noopener noreferrer"&gt;Bash trap &amp;amp; Signal Handler Builder&lt;/a&gt; generates the exact signal block an entrypoint needs.&lt;/p&gt;

&lt;p&gt;Deploys get the same treatment: deploy into a fresh timestamped directory, flip a &lt;code&gt;current&lt;/code&gt; symlink atomically with &lt;code&gt;ln -sfn&lt;/code&gt; so traffic never sees a half-written release, keep the last several releases so rollback is just re-pointing the symlink, run a health check after the swap and fail the deploy if it doesn't pass, and stamp the git SHA into the release so "what's running right now" always has an answer. And when a step fails and the logs won't say why, &lt;code&gt;set -x&lt;/code&gt; around just the suspect section shows you each command with its variables expanded — a doubled slash or an empty segment in the trace is usually your bug standing in plain sight.&lt;/p&gt;

&lt;p&gt;The full guide is the field manual version of all of this — the four failure modes, the safe header, secret validation with a &lt;code&gt;validate_env&lt;/code&gt; function, &lt;code&gt;PIPESTATUS&lt;/code&gt; and &lt;code&gt;pipefail&lt;/code&gt;, Docker entrypoints, the atomic-symlink deploy script with rollback and health check, debugging with &lt;code&gt;set -x&lt;/code&gt;, and a production-ready checklist at the end: &lt;a href="https://bashsnippets.xyz/guides/bash-scripting-for-ci-cd-pipelines" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/bash-scripting-for-ci-cd-pipelines&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your pipeline scripts are dying on the small stuff first — unquoted loops, bad argument parsing, missing traps — the snippet library that feeds into this guide is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>devops</category>
      <category>cicd</category>
      <category>docker</category>
    </item>
    <item>
      <title>My Script Crashed and Left a Lock File Behind. Every Run After That Refused to Start.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:56:45 +0000</pubDate>
      <link>https://dev.to/bashsnippets/my-script-crashed-and-left-a-lock-file-behind-every-run-after-that-refused-to-start-1ik4</link>
      <guid>https://dev.to/bashsnippets/my-script-crashed-and-left-a-lock-file-behind-every-run-after-that-refused-to-start-1ik4</guid>
      <description>&lt;p&gt;A backup script of mine created a lock file on startup so two copies couldn't run at once — sensible. Then one night it hit an error partway through, &lt;code&gt;set -e&lt;/code&gt; killed it on the spot, and it died without ever reaching the line that removes the lock. The lock file sat there. Every scheduled run for the next three days started up, saw the lock, printed "already running," and exited immediately. No backups ran. The cron job was firing perfectly on time and doing nothing, and the only symptom was an absence — backups that simply weren't there — until I went looking and found a stale lock from Tuesday.&lt;/p&gt;

&lt;p&gt;The fix is a &lt;code&gt;trap&lt;/code&gt;. A trap registers a cleanup handler that runs when the script exits &lt;em&gt;for any reason&lt;/em&gt; — clean finish, &lt;code&gt;set -e&lt;/code&gt; failure, Ctrl+C, &lt;code&gt;kill&lt;/code&gt;. Put the lock removal in an EXIT trap and it runs no matter how the script dies. The lock would have been gone the instant that backup crashed, and the next run would have started fine.&lt;/p&gt;

&lt;p&gt;So why did I write the script without one? Because I could never remember the syntax cold. Single quotes or double? Which signals? Does EXIT fire on &lt;code&gt;exit 1&lt;/code&gt; or only on a clean finish? How do I get the exit code inside the handler? Every time I needed a trap I ended up with three browser tabs open, reading the same Stack Overflow answers, second-guessing the quoting. Under pressure, in the middle of fixing something else, that friction is exactly when people skip the trap entirely — which is how I ended up with the stale lock in the first place.&lt;/p&gt;

&lt;p&gt;So I built the thing I kept wishing existed: a &lt;strong&gt;Bash trap &amp;amp; Signal Handler Builder&lt;/strong&gt;. You pick the signals you want to handle, check off the cleanup actions you need, and it writes a correct, ShellCheck-clean trap block you paste into your script. No tabs, no second-guessing the quoting.&lt;/p&gt;

&lt;p&gt;It covers the signals that actually come up. &lt;strong&gt;EXIT&lt;/strong&gt; — fires on every exit, the one that should carry your cleanup. &lt;strong&gt;ERR&lt;/strong&gt; — fires when a command fails under &lt;code&gt;set -e&lt;/code&gt;, the one that logs the exact failing line with &lt;code&gt;$LINENO&lt;/code&gt;. &lt;strong&gt;INT&lt;/strong&gt; — Ctrl+C. &lt;strong&gt;TERM&lt;/strong&gt; — what &lt;code&gt;kill&lt;/code&gt;, &lt;code&gt;systemctl stop&lt;/code&gt;, and &lt;code&gt;docker stop&lt;/code&gt; send. &lt;strong&gt;HUP&lt;/strong&gt; — terminal closed or SSH dropped. &lt;strong&gt;PIPE&lt;/strong&gt; — writing to a closed pipe. Each one has a one-line reminder of when it fires and what it's good for, because half the battle is just remembering that TERM is the one Docker sends.&lt;/p&gt;

&lt;p&gt;The cleanup actions are the things people forget until a crash makes them care: remove temp files (with the &lt;code&gt;TMPFILE=$(mktemp)&lt;/code&gt; declaration wired in up top), remove a lock file — the exact failure that bit me — stop background jobs the script started, log the exit reason with the code, and restore the terminal cursor. Tick the ones you need and they land in the handler.&lt;/p&gt;

&lt;p&gt;The generated code isn't a toy snippet. It single-quotes the trap so the handler resolves when the signal fires instead of at definition time — the SC2064 gotcha most hand-written traps get wrong. It includes an idempotency guard so that when ERR and EXIT both fire on the same failure, your cleanup runs exactly once instead of twice. The ERR handler captures &lt;code&gt;$LINENO&lt;/code&gt; so you find out which line actually blew up. I ran the generator's output through ShellCheck across a dozen different configurations while building it, and every one comes back clean — the whole point was that you can paste it and trust it, not paste it and then go debug the thing that was supposed to save you debugging.&lt;/p&gt;

&lt;p&gt;There are two copy buttons, because there are two situations. "Copy trap block only" when you've already got a script and just want to drop the traps in. "Copy complete script header" when you're starting fresh and want the shebang, &lt;code&gt;set -euo pipefail&lt;/code&gt;, the &lt;code&gt;CHECK&lt;/code&gt;/&lt;code&gt;CROSS&lt;/code&gt; vars, the resource declarations, and the handler all assembled in the right order. It only declares the variables the generated code actually uses, so you never paste in a &lt;code&gt;CROSS&lt;/code&gt; you never reference and get a ShellCheck warning for your trouble.&lt;/p&gt;

&lt;p&gt;The lock-file incident cost me three days of silently missing backups and ten minutes of feeling foolish when I found the cause. The trap that would have prevented it is four lines. The reason I didn't have those four lines was pure friction — I couldn't recall the syntax fast enough to be bothered in the moment. This tool removes the friction, which is the only thing that was ever standing between me and a correct script.&lt;/p&gt;

&lt;p&gt;Build your trap block here — pick signals, pick cleanup actions, copy clean code: &lt;a href="https://bashsnippets.xyz/tools/bash-trap-builder" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools/bash-trap-builder&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want the full picture of where traps fit — strict mode, cleanup, the failure modes that make them necessary — the &lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling" rel="noopener noreferrer"&gt;Bash Error Handling&lt;/a&gt; snippet is the companion read, and the rest of the tools are at &lt;a href="https://bashsnippets.xyz/tools" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Packaged the Scripts I Copy to Every New Server Into a $9 Toolkit. Here's What's In It and Why.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 09 Jun 2026 15:44:19 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-packaged-the-scripts-i-copy-to-every-new-server-into-a-9-toolkit-heres-whats-in-it-and-why-cn6</link>
      <guid>https://dev.to/bashsnippets/i-packaged-the-scripts-i-copy-to-every-new-server-into-a-9-toolkit-heres-whats-in-it-and-why-cn6</guid>
      <description>&lt;p&gt;Every time I provision a new server — whether it's a $5 DigitalOcean droplet for a side project or a client's production box — there's a set of scripts I copy to &lt;code&gt;/opt/scripts&lt;/code&gt; before I do anything else.&lt;/p&gt;

&lt;p&gt;Not after the app is deployed. Not after the first incident. Before I touch the application at all. Before I configure nginx. Before I set up the database. The monitoring layer goes in first because the first time you need it, you needed it yesterday.&lt;/p&gt;

&lt;p&gt;Disk monitoring that fires before the outage. A backup pipeline with automatic retention. SSL certificate checks that run daily at 8am so I'm not finding out from a user's email. A service watchdog that restarts nginx or Postgres within 60 seconds of a crash instead of six hours later when someone notices the site is down.&lt;/p&gt;

&lt;p&gt;These scripts took me about two years of production incidents to build. Not because they're complicated — they're not. Each one is 15-50 lines. Because each one was built in response to a specific failure where I didn't have the thing I needed and had to build it under pressure at a bad time of day.&lt;/p&gt;

&lt;p&gt;I open-sourced the basic versions on BashSnippets.xyz. Those are free and they'll stay free. But the versions I actually run in production are different from the tutorial versions in ways that matter — and that gap is what I packaged into the toolkit.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Different Between the Free Snippets and the Toolkit
&lt;/h2&gt;

&lt;p&gt;The free snippets on the site are single-purpose scripts that each solve one problem. They're complete. They work. If all you need is a disk space check, the free version does that.&lt;/p&gt;

&lt;p&gt;The toolkit versions are built as a system.&lt;/p&gt;

&lt;p&gt;There's a shared library — &lt;code&gt;bashlib.sh&lt;/code&gt; — with 31 functions that every script sources. Logging, color output, email alerts, error handling, lock file management, threshold checks, dry-run support. Instead of each script reimplementing its own &lt;code&gt;log()&lt;/code&gt; function and its own error handling and its own email logic, they all call &lt;code&gt;bashlib.sh&lt;/code&gt; and get consistent behavior across the board.&lt;/p&gt;

&lt;p&gt;That means when I change how logging works, it changes everywhere. When I add Slack webhook support to the alert function, every script that calls &lt;code&gt;alert()&lt;/code&gt; gets Slack notifications without any changes to the script itself. The shared library is the infrastructure layer that turns six standalone scripts into a cohesive system.&lt;/p&gt;

&lt;p&gt;The free snippets don't have this because a shared library adds a dependency — &lt;code&gt;bashlib.sh&lt;/code&gt; has to exist at a known path, the scripts have to source it at startup, and if someone downloads one script without the library it breaks. That's fine for a toolkit you install as a package. It's bad for a tutorial page where someone wants to copy-paste one script and have it work immediately. Both approaches are correct for their context.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's in the Box
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;6 production scripts:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each one follows the same structure — &lt;code&gt;set -euo pipefail&lt;/code&gt;, sourcing &lt;code&gt;bashlib.sh&lt;/code&gt;, named variables for every threshold and path (no magic numbers buried in command pipelines), comments explaining not just what each line does but why it exists, and explicit non-zero exits on every failure path.&lt;/p&gt;

&lt;p&gt;The scripts cover disk space monitoring, database backup with retention, SSL certificate expiry checking across multiple domains, service watchdog with automatic restart, log rotation and cleanup, and system health reporting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;bashlib.sh — the shared library (31 functions):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part I'm most particular about. Functions for timestamped logging with severity levels. Color-coded terminal output that degrades gracefully when piped to a file (no escape codes in your log files). Email and webhook alerting. Lock file acquisition with stale-lock detection. Dry-run mode that every function respects. PID file management. Threshold comparison helpers. Configuration file loading.&lt;/p&gt;

&lt;p&gt;Every function is documented with a usage comment. Every function handles its own error cases. The library passes ShellCheck with zero warnings at every severity level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;template.sh:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A starter template that sources &lt;code&gt;bashlib.sh&lt;/code&gt;, sets up traps, parses arguments, and has placeholder sections for your own logic. When I need a new script on a server, I copy this template, fill in the business logic, and the error handling and logging are already done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;52-page field guide (PDF):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not an API reference. Not a man page reformatted as a PDF. A field guide — structured as the things you need to know in the order you need to know them when you're setting up automation on a new server.&lt;/p&gt;

&lt;p&gt;Covers the why behind every pattern in the scripts. Why &lt;code&gt;set -euo pipefail&lt;/code&gt; and what each flag actually prevents. Why traps on EXIT instead of just INT. Why lock files need stale detection. Why backup retention has to be a separate step from the backup itself. Why SSL monitoring should be independent of your renewal tool.&lt;/p&gt;

&lt;p&gt;Each section includes the real failure scenario that motivated the pattern, because "best practice" without the consequence attached is advice that gets skipped.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why $9
&lt;/h2&gt;

&lt;p&gt;I thought about this for a while. The scripts are worth more than $9 to anyone who's going to use them — preventing one 4am incident pays for the toolkit immediately. But I also know what it's like to be the person running a $5 VPS on a budget, and I wanted the price to be low enough that buying it doesn't require approval from anyone or a second thought.&lt;/p&gt;

&lt;p&gt;$9. MIT license. Unlimited personal and commercial use. You can deploy these on client servers, modify them however you want, include them in your own automation. No subscription. No upsell. No "starter tier" with premium features behind another paywall.&lt;/p&gt;

&lt;p&gt;The free snippets on the site are not going away. They're not a demo. They're complete, working scripts that I actively maintain. The toolkit is for people who want the production layer — the shared library, the integrated system, the field guide that ties it together — and want it in one download instead of building it themselves.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;If you're managing one or more Linux servers and you don't already have automated monitoring, backups, and alerting set up — this is the fastest path to having all three. Copy the scripts to the server, edit the config variables at the top of each file, add the cron entries from the guide, and you have a monitoring layer that didn't exist 10 minutes ago.&lt;/p&gt;

&lt;p&gt;If you already have these things set up and you built them yourself, you probably don't need this. You might find something useful in &lt;code&gt;bashlib.sh&lt;/code&gt; — the stale lock detection or the dry-run mode — but the scripts themselves won't tell you anything new.&lt;/p&gt;

&lt;p&gt;If you're learning bash and want to see how production scripts are structured differently from tutorial scripts, the field guide is probably the most useful part. The "why" sections explain patterns that most bash tutorials skip because they're not relevant to a single-file script running on a laptop.&lt;/p&gt;




&lt;h2&gt;
  
  
  Every Script Passes ShellCheck at Zero Warnings
&lt;/h2&gt;

&lt;p&gt;This one matters to me. ShellCheck is the static analysis tool for bash. It catches the bugs that work on happy-path input and break on edge cases — unquoted variables that split on whitespace, pipelines that swallow exit codes, deprecated syntax that newer bash versions handle differently.&lt;/p&gt;

&lt;p&gt;Every script in the toolkit, including &lt;code&gt;bashlib.sh&lt;/code&gt;, passes ShellCheck at the strictest severity level with zero warnings. Not "a few style notes we decided were fine." Zero. I treat ShellCheck warnings the same way I treat compiler warnings in C — they are bugs I haven't hit yet, and ignoring them is technical debt with a guaranteed due date.&lt;/p&gt;

&lt;p&gt;If you run ShellCheck on these files and get output, something went wrong and I want to know about it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Link
&lt;/h2&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/starter-kit" rel="noopener noreferrer"&gt;bashsnippets.xyz/starter-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;$9. Instant download. 6 production scripts + &lt;code&gt;bashlib.sh&lt;/code&gt; shared library (31 functions) + &lt;code&gt;template.sh&lt;/code&gt; + 52-page field guide. ShellCheck-clean. MIT license.&lt;/p&gt;

&lt;p&gt;Already have scripts and need somewhere to run them? DigitalOcean droplets start at $4/month and you can get $200 in free credit to start:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://m.do.co/c/7a196437764c" rel="noopener noreferrer"&gt;Get $200 free credit — DigitalOcean&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The free snippet library with 17+ scripts and 7 interactive tools is at:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>beginners</category>
    </item>
    <item>
      <title>6 small things we shipped across the BashSnippets tools this week</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 09 Jun 2026 03:49:07 +0000</pubDate>
      <link>https://dev.to/bashsnippets/6-small-things-we-shipped-across-the-bashsnippets-tools-this-week-287d</link>
      <guid>https://dev.to/bashsnippets/6-small-things-we-shipped-across-the-bashsnippets-tools-this-week-287d</guid>
      <description>&lt;p&gt;Nobody announces small features. You ship them, they're in there, and the people who find them either notice or they don't. I want to start documenting these because some of them are the kind of thing that makes a tool actually worth using day-to-day instead of being something you visit once and close.&lt;/p&gt;

&lt;p&gt;Six updates across six tools this week. None of them are headline features. All of them are fixes for specific annoyances that came up in my own usage, which is the only real source I trust for "does this actually help."&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Bash Boilerplate Generator — Trap &amp;amp; Cleanup Handler
&lt;/h2&gt;

&lt;p&gt;There's a now a toggle for trap handling. When on, the generated template includes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;# Remove temp files, release locks, undo partial changes&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;trap &lt;/span&gt;cleanup EXIT INT TERM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this matters: &lt;code&gt;trap cleanup EXIT&lt;/code&gt; means the &lt;code&gt;cleanup()&lt;/code&gt; function runs no matter how the script exits. Normal completion. &lt;code&gt;Ctrl+C&lt;/code&gt;. An uncaught error. A &lt;code&gt;kill&lt;/code&gt; signal. The cleanup function runs. Every time.&lt;/p&gt;

&lt;p&gt;Without this pattern, scripts that create temp files leave them behind when they're interrupted. Scripts that acquire lock files leave them locked. Scripts that make partial changes — creating a directory, writing part of a config — leave the system in an inconsistent state because the cleanup code at the end of the script never ran.&lt;/p&gt;

&lt;p&gt;The trap-on-exit pattern is not optional for anything running unattended. It's the thing that separates "runs fine when nothing goes wrong" from "also recovers gracefully when something does." I've seen this pattern left out of boilerplate generators enough times that I wanted to make it explicit and easy to include.&lt;/p&gt;

&lt;p&gt;The generated stub includes a &lt;code&gt;TMPFILE=$(mktemp)&lt;/code&gt; line as a concrete example of something that needs cleanup. Replace it with whatever state your script actually manages.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/tools/bash-boilerplate-generator" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools/bash-boilerplate-generator&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Bash Exit Code Lookup — Export as &lt;code&gt;case&lt;/code&gt; Statement
&lt;/h2&gt;

&lt;p&gt;After looking up an exit code, there's a button that generates a ready-to-paste &lt;code&gt;case $? in ... esac&lt;/code&gt; block with the explanation inline as a comment.&lt;/p&gt;

&lt;p&gt;Before this, the lookup gave you the meaning of the exit code and you had to write the case handler yourself. That's not hard — the case syntax is simple — but it's friction. You looked up what &lt;code&gt;126&lt;/code&gt; means ("command found but not executable — check permissions"), now you have to translate that into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;0&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Success"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  1&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"General error"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  126&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Command found but not executable — check file permissions"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;  &lt;span class="c"&gt;# ← the one you just looked up&lt;/span&gt;
  127&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Command not found — check PATH or typo"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Unknown exit code: &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The export button generates that block for you, with the specific code you looked up highlighted in the right place and the explanation preserved as a comment. Paste it directly into your script. No rewriting the syntax from scratch.&lt;/p&gt;

&lt;p&gt;The case template includes the four most common exit codes (0, 1, 126, 127) plus the wildcard catch-all. Delete the ones you don't need.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/tools/bash-exit-code-lookup" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools/bash-exit-code-lookup&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Cron Job Builder — Next 5 Run Times
&lt;/h2&gt;

&lt;p&gt;This is the one I wanted for myself.&lt;/p&gt;

&lt;p&gt;Enter any cron expression — say, &lt;code&gt;0 3 * * 1-5&lt;/code&gt; — and it now immediately shows the next five times it will fire:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Next run times for: 0 3 * * 1-5

1.  Mon Jun 09 2026  03:00:00
2.  Tue Jun 10 2026  03:00:00
3.  Wed Jun 11 2026  03:00:00
4.  Thu Jun 12 2026  03:00:00
5.  Fri Jun 13 2026  03:00:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem this solves: cron expressions are easy to get subtly wrong. &lt;code&gt;0 3 * * *&lt;/code&gt; fires at 3am. But 3am what — your server's local time or UTC? And is your server set to UTC? And does &lt;code&gt;*/6&lt;/code&gt; mean every 6 hours starting at midnight, or starting at the first minute it's defined? And does &lt;code&gt;0 9 * * 1&lt;/code&gt; fire on Monday or Sunday, depending on which cron implementation counts week starts?&lt;/p&gt;

&lt;p&gt;Without something that shows you the actual fire times, you add the job, wait until the next day, and find out whether your assumptions were right or wrong. If they were wrong, you change it and wait another day. It can take three or four days to confirm a cron expression fires when you want it to, purely because of iteration time.&lt;/p&gt;

&lt;p&gt;Showing the next five run times eliminates that cycle entirely. You know immediately whether your expression does what you think it does. Change it, verify it, add it to your crontab — all in under a minute.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/tools/cron-job-builder" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools/cron-job-builder&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Chmod Calculator — World-Writable Warning and Live Symbolic Mirror
&lt;/h2&gt;

&lt;p&gt;Two updates here bundled together because they're related.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;World-writable warning:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enabling any world-write bit (the &lt;code&gt;w&lt;/code&gt; in the "others" column) now shows a warning banner:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ World-writable permissions mean any user on the system can modify this file or directory. This is rarely correct. Verify this is intentional.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the &lt;code&gt;chmod 777&lt;/code&gt; trap. I've seen &lt;code&gt;chmod 777&lt;/code&gt; applied as a "quick fix" for permission errors more times than I can count. It works immediately, which is why people do it. The fact that it means "literally every user and process on this system can write to this file" gets lost in the urgency of making the error go away.&lt;/p&gt;

&lt;p&gt;The warning doesn't prevent you from setting world-writable permissions. It just makes sure you've seen the words "any user on the system can modify this" before you click confirm. If you intended that, the warning is just noise. If you didn't, it might save you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live symbolic ↔ octal mirror:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every permission you configure now shows both forms simultaneously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Octal:    &lt;span class="nb"&gt;chmod &lt;/span&gt;755
Symbolic: &lt;span class="nb"&gt;chmod &lt;/span&gt;&lt;span class="nv"&gt;u&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rwx,go&lt;span class="o"&gt;=&lt;/span&gt;rx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both update in real time as you toggle permissions. This is useful for two reasons: you see the octal code for when you need to type it quickly in a terminal, and you see the symbolic form for when you need to express the same permission in a script where the explicit form is easier to read and maintain. Neither is "more correct" — they're the same thing expressed two ways, and having both removes the mental step of converting between them.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/tools/chmod-permissions-builder" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools/chmod-permissions-builder&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  5. PATH Debugger — Duplicate Entry Detector
&lt;/h2&gt;

&lt;p&gt;Duplicate entries in &lt;code&gt;$PATH&lt;/code&gt; accumulate over years. Every time a tool's installer adds itself to your PATH, every time you add an entry to &lt;code&gt;.bashrc&lt;/code&gt;, every time a package manager prepends its bin directory to your environment — the list grows. On machines that have been around for a while, or on developer laptops where tools get installed and removed and reinstalled, you can end up with &lt;code&gt;/usr/local/bin&lt;/code&gt; listed four times.&lt;/p&gt;

&lt;p&gt;Duplicate entries don't usually cause obvious breakage. The correct binary still runs. It just runs with slightly more PATH resolution overhead on every command, and the duplicate entries make it harder to reason about which version of a tool will actually be picked when you have multiple installed. If &lt;code&gt;/usr/local/bin&lt;/code&gt; appears before &lt;code&gt;/usr/bin&lt;/code&gt;, the local install wins. When it appears four times, you've lost track of the actual resolution order.&lt;/p&gt;

&lt;p&gt;The debugger now flags repeated entries and generates a one-liner to deduplicate and export a clean PATH:&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;# Generated dedup command:&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&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;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'!seen[$0]++'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;':'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/:$//'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pipeline: converts PATH from colon-separated to newline-separated, uses awk to keep only the first occurrence of each entry, converts back to colon-separated, and strips the trailing colon. The resulting PATH has the same resolution order as the original but with all duplicates removed.&lt;/p&gt;

&lt;p&gt;Add that line to the bottom of your &lt;code&gt;.bashrc&lt;/code&gt; and your PATH will deduplicate itself on every new shell session.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/tools/path-debugger" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools/path-debugger&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  6. ShellCheck Error Decoder — Inline Script Scanner
&lt;/h2&gt;

&lt;p&gt;ShellCheck is the best static analysis tool for bash scripts. If you're writing bash and you're not running your scripts through ShellCheck, you're missing real bugs. I'll say that plainly.&lt;/p&gt;

&lt;p&gt;The problem is that ShellCheck error codes (SC2086, SC2046, SC1091, etc.) are meaningful if you know what they mean and opaque if you don't. When ShellCheck tells you &lt;code&gt;SC2086: Double quote to prevent globbing and word splitting&lt;/code&gt;, that makes sense. When it just gives you &lt;code&gt;SC2086&lt;/code&gt; in isolation, you have to look it up.&lt;/p&gt;

&lt;p&gt;The inline script scanner adds a paste box where you put your bash script directly. The tool maps recognized SC error codes to the lines in your script where they appear — not in the abstract documentation, but in your specific code. You see the line, the SC code, and the explanation together.&lt;/p&gt;

&lt;p&gt;Important caveat I want to be direct about: &lt;strong&gt;this is not a replacement for running actual ShellCheck&lt;/strong&gt;. The real ShellCheck tool parses bash properly, handles edge cases, and catches issues that a pattern-matcher won't. If you're writing scripts that matter, install ShellCheck and run it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
apt &lt;span class="nb"&gt;install &lt;/span&gt;shellcheck       &lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;shellcheck      &lt;span class="c"&gt;# macOS&lt;/span&gt;

&lt;span class="c"&gt;# Run&lt;/span&gt;
shellcheck yourscript.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the decoder in BashSnippets tools is useful for: understanding what a specific SC code means in the context of your own code rather than in a reference page. It's a learning tool and a quick lookup, not a substitute for the real thing.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/tools/shellcheck-error-decoder" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools/shellcheck-error-decoder&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;All tools are free, no login, no account required. The full tools index is at:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/tools" rel="noopener noreferrer"&gt;bashsnippets.xyz/tools&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>resources</category>
    </item>
    <item>
      <title>I was handed a server with no docs and no idea what it was listening on. One command fixed that.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 09 Jun 2026 00:04:46 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-was-handed-a-server-with-no-docs-and-no-idea-what-it-was-listening-on-one-command-fixed-that-eh2</link>
      <guid>https://dev.to/bashsnippets/i-was-handed-a-server-with-no-docs-and-no-idea-what-it-was-listening-on-one-command-fixed-that-eh2</guid>
      <description>&lt;p&gt;A client handed me SSH access to a server they'd been running for two years.&lt;/p&gt;

&lt;p&gt;No documentation. No handoff notes. No "here's what's running and why." Just a root password in a LastPass share and a Slack message that said "it hosts our web app, let us know if you need anything."&lt;/p&gt;

&lt;p&gt;I didn't know what the web app was. I didn't know what was installed. I didn't know what ports were open to the internet, what services were running, or what this machine had been doing quietly for 730 days before I touched it.&lt;/p&gt;

&lt;p&gt;This is not unusual. This is actually most of the "inherited server" situations I've been in. The person who set it up is gone, or was a contractor, or was the founder who also did DevOps because someone had to, and the documentation lived entirely inside one person's head and left with them.&lt;/p&gt;

&lt;p&gt;The first thing I do on an unfamiliar server is not grep logs or check running services. It's figure out what the machine is actually reachable on from the outside. Not what anyone says it's supposed to be doing. What it &lt;em&gt;is&lt;/em&gt; doing. What ports are in LISTEN state, what process owns each one, and whether that process is bound to localhost or to every network interface on the machine.&lt;/p&gt;

&lt;p&gt;I ran &lt;code&gt;netstat -an&lt;/code&gt; first, because that's what I knew at the time. I got back 200 lines of connection state. TIME_WAIT entries for connections that had already closed. ESTABLISHED entries for active sessions. CLOSE_WAIT entries from something that hadn't cleaned up properly. All of it technically useful for debugging specific connection issues. None of it useful for getting a fast security picture of what's actually listening for new connections.&lt;/p&gt;

&lt;p&gt;Finding the ports in LISTEN state in &lt;code&gt;netstat -an&lt;/code&gt; output feels like searching for a specific ingredient in a paragraph of text. It's all there, but you have to read every line.&lt;/p&gt;

&lt;p&gt;Then someone showed me &lt;code&gt;ss&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-t&lt;/code&gt; — TCP only (use &lt;code&gt;-u&lt;/code&gt; for UDP, or &lt;code&gt;-tu&lt;/code&gt; for both)&lt;br&gt;&lt;br&gt;
&lt;code&gt;-l&lt;/code&gt; — listening sockets only (filters out ESTABLISHED, TIME_WAIT, everything else)&lt;br&gt;&lt;br&gt;
&lt;code&gt;-n&lt;/code&gt; — numeric output (don't resolve port numbers to service names, don't do reverse DNS)&lt;br&gt;&lt;br&gt;
&lt;code&gt;-p&lt;/code&gt; — show the process name and PID holding each socket&lt;/p&gt;

&lt;p&gt;Output on a typical web server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;State   Recv-Q  Send-Q  Local Address:Port   Peer Address:Port  Process
LISTEN  0       128     0.0.0.0:22           0.0.0.0:*          users:(("sshd",pid=1234,fd=3))
LISTEN  0       511     0.0.0.0:80           0.0.0.0:*          users:(("nginx",pid=5678,fd=6))
LISTEN  0       511     0.0.0.0:443          0.0.0.0:*          users:(("nginx",pid=5678,fd=7))
LISTEN  0       128     0.0.0.0:5432         0.0.0.0:*          users:(("postgres",pid=9012,fd=5))
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 22 — SSH. Expected.&lt;br&gt;&lt;br&gt;
Port 80, 443 — nginx. Expected.&lt;br&gt;&lt;br&gt;
Port 5432 — Postgres. Bound to &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0.0.0.0:5432&lt;/code&gt; means Postgres was accepting connections from any IP address on the internet. Not just localhost. Not just the application server. Any IP. Port 5432, wide open, reachable from anywhere.&lt;/p&gt;

&lt;p&gt;It had a strong password. It had been running that way for two years without an incident anyone knew about. But "strong password" is not a substitute for "not exposed to the internet." A service that should only accept connections from localhost or from your application tier has no business being bound to &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One command. Three seconds. Caught a misconfiguration that had been sitting there since the server was provisioned.&lt;/p&gt;


&lt;h2&gt;
  
  
  What the Output Is Actually Telling You
&lt;/h2&gt;

&lt;p&gt;The column that matters is &lt;code&gt;Local Address&lt;/code&gt;. This is where the socket is bound — who it will accept connections from.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0.0.0:5432    → Externally reachable on all IPv4 interfaces
127.0.0.1:5432  → Localhost only — not reachable from outside
:::5432         → All IPv6 interfaces (equivalent to 0.0.0.0 for IPv6)
::1:5432        → IPv6 localhost only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a service should only be accessed from the same machine — a database, an internal cache, a monitoring agent — it should be bound to &lt;code&gt;127.0.0.1&lt;/code&gt;. If you see it bound to &lt;code&gt;0.0.0.0&lt;/code&gt; and you don't have an explicit reason for that, that's a conversation to have with whoever configured the service.&lt;/p&gt;

&lt;p&gt;For the Postgres case, the fix is one line in &lt;code&gt;postgresql.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;listen_addresses&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;'localhost'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Postgres, confirm the bind address changed with &lt;code&gt;ss -tlnp&lt;/code&gt; again, done. That configuration change is a 30-second fix. The two years it had been wrong before someone checked is the part that should concern you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why &lt;code&gt;ss&lt;/code&gt; Instead of &lt;code&gt;netstat&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ss&lt;/code&gt; replaced &lt;code&gt;netstat&lt;/code&gt; on most Linux distributions when &lt;code&gt;iproute2&lt;/code&gt; superseded &lt;code&gt;net-tools&lt;/code&gt; around 2016. On a fresh Ubuntu or Debian install, &lt;code&gt;netstat&lt;/code&gt; isn't installed by default — it's in the &lt;code&gt;net-tools&lt;/code&gt; package which isn't pulled in automatically anymore. That's why you've probably SSH'd into a server, typed &lt;code&gt;netstat&lt;/code&gt;, and gotten &lt;code&gt;command not found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ss&lt;/code&gt; reads kernel socket tables directly instead of going through &lt;code&gt;/proc/net/tcp&lt;/code&gt;. It's faster on machines with thousands of connections, and it's maintained. &lt;code&gt;net-tools&lt;/code&gt; has been in maintenance-only mode for years. For new work, use &lt;code&gt;ss&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That said, &lt;code&gt;netstat -tlnp&lt;/code&gt; does the same thing if you have it installed. The flags are identical. If you're on an older system or have &lt;code&gt;net-tools&lt;/code&gt; available, either works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Process Name Caveat
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;ss -tlnp&lt;/code&gt; without &lt;code&gt;sudo&lt;/code&gt; and you'll see the ports and bind addresses, but the &lt;code&gt;Process&lt;/code&gt; column will be empty for sockets owned by other users. You'll see the port, you won't see what's holding 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="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;sudo&lt;/code&gt;, you see everything — the socket, the process name, the PID, the file descriptor number. That's the version to run when you're doing a security audit on a machine you have root on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;lsof&lt;/code&gt; Alternative
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;lsof -i -P -n | grep LISTEN&lt;/code&gt; gives you the same information from a different angle — process name first, port second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;nginx    5678 root   6u  IPv4  12345  0t0  TCP *:80 (LISTEN)
nginx    5678 root   7u  IPv4  12346  0t0  TCP *:443 (LISTEN)
sshd     1234 root   3u  IPv4  12347  0t0  TCP *:22 (LISTEN)
postgres 9012 postgres 5u IPv4 12348  0t0  TCP *:5432 (LISTEN)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful when you know the service name and want to find its ports. Less useful when you want a port-first view of everything listening. I use &lt;code&gt;ss&lt;/code&gt; for the initial audit and &lt;code&gt;lsof&lt;/code&gt; when I'm tracking down a specific process.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Do With Ports I Can't Identify
&lt;/h2&gt;

&lt;p&gt;When I see a port in LISTEN state and the process name isn't immediately obvious — &lt;code&gt;java&lt;/code&gt;, &lt;code&gt;python3&lt;/code&gt;, something that isn't a clear service name — I do three things:&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;# 1. Find the full command that started the process&lt;/span&gt;
ps aux | &lt;span class="nb"&gt;grep&lt;/span&gt; &amp;lt;PID&amp;gt;

&lt;span class="c"&gt;# 2. Check what package installed the binary&lt;/span&gt;
dpkg &lt;span class="nt"&gt;-S&lt;/span&gt; /path/to/binary    &lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
rpm &lt;span class="nt"&gt;-qf&lt;/span&gt; /path/to/binary    &lt;span class="c"&gt;# RHEL/CentOS&lt;/span&gt;

&lt;span class="c"&gt;# 3. Check what the process has open (files, connections, everything)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lsof &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;PID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of the time it's something benign — a monitoring agent, a language runtime for an app, a service the previous admin installed and forgot about. Occasionally it's something that shouldn't be there at all. You don't know until you check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building This Into Your Server Checklist
&lt;/h2&gt;

&lt;p&gt;Every time I take over a server now, this is the third command I run. After &lt;code&gt;uptime&lt;/code&gt; (how long has it been running) and &lt;code&gt;df -h&lt;/code&gt; (is it about to run out of disk), it's &lt;code&gt;sudo ss -tlnp&lt;/code&gt; (what is this machine telling the internet it's listening on).&lt;/p&gt;

&lt;p&gt;Takes three seconds. Has caught real problems more than once. The Postgres &lt;code&gt;0.0.0.0&lt;/code&gt; situation is the one I remember most clearly, but it's not the only one — I've found web apps listening on non-standard ports that were supposed to be internal, Redis instances bound to &lt;code&gt;0.0.0.0&lt;/code&gt; on development machines that got promoted to production without anyone auditing the config, SSH running on a second non-standard port "for convenience" that had been left open after a migration.&lt;/p&gt;

&lt;p&gt;None of those were catastrophic on their own. All of them were things the people running those servers didn't know were there.&lt;/p&gt;

&lt;p&gt;Three seconds to find out. I don't know why I didn't run this on every server from the beginning.&lt;/p&gt;




&lt;p&gt;Full script with UDP ports, both TCP and UDP in one pass, &lt;code&gt;lsof&lt;/code&gt; cross-reference, and notes on what to do when you can't identify a process:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/snippets/list-open-ports-linux" rel="noopener noreferrer"&gt;bashsnippets.xyz/snippets/list-open-ports-linux&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>security</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>certbot had been auto-renewing my certs for two years. Until it wasn't.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Sun, 07 Jun 2026 05:08:19 +0000</pubDate>
      <link>https://dev.to/bashsnippets/certbot-had-been-auto-renewing-my-certs-for-two-years-until-it-wasnt-1gpi</link>
      <guid>https://dev.to/bashsnippets/certbot-had-been-auto-renewing-my-certs-for-two-years-until-it-wasnt-1gpi</guid>
      <description>&lt;h2&gt;
  
  
  certbot had been running quietly on my server for almost two years without a single issue.
&lt;/h2&gt;

&lt;p&gt;Automatic renewals. Silent cron job. I never thought about it. Set it up once, tested it once, and mentally moved it to the "solved" column of my infrastructure checklist. That column felt good. certbot goes in there and stays there.&lt;/p&gt;

&lt;p&gt;Then I checked my site on a Monday morning and got the red browser warning.&lt;/p&gt;

&lt;p&gt;Not "connection is slow." Not a timeout. The full-page block. Chrome's red lock icon. "Your connection is not private." NET::ERR_CERT_DATE_INVALID in small gray text underneath, in case you wanted the clinical version of bad news.&lt;/p&gt;

&lt;p&gt;I SSHd in immediately. The cert had expired three days earlier.&lt;/p&gt;

&lt;p&gt;certbot had been running, technically. The cron job was firing. No errors in &lt;code&gt;/var/log/syslog&lt;/code&gt; that I was watching. But the HTTP challenge was failing silently — something to do with a port conflict during renewal. A service I'd added a few months back was binding to port 80 for its own health check, and certbot couldn't complete the ACME challenge because port 80 wasn't free during the brief window it needed it. The renewal attempt failed. certbot logged it somewhere I wasn't watching. The cron job reported success because the cron job ran — it just happened to run a certbot process that quietly gave up.&lt;/p&gt;

&lt;p&gt;No alert email. No log message in any file I had on my radar. Just a quiet failure, three renewal cycles in a row, and then an expired certificate.&lt;/p&gt;

&lt;p&gt;I found out from a user who emailed me. Not from monitoring. Not from a script. From a stranger who was kind enough to say "hey, your SSL is broken" instead of just closing the tab.&lt;/p&gt;

&lt;p&gt;The renewal fix took five minutes once I found the port conflict. What I didn't have — what I wish I'd had — was a script that told me the cert was about to expire &lt;em&gt;before&lt;/em&gt; it happened. Something running every morning, checking the actual live certificate the way a browser would check it, and reporting "you have 18 days left — go fix this."&lt;/p&gt;

&lt;p&gt;Turns out &lt;code&gt;openssl&lt;/code&gt; can do this in one command. It's been able to do this for years. I just never looked.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; yoursite.com:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; yoursite.com &lt;span class="se"&gt;\&lt;/span&gt;
  2&amp;gt;/dev/null | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that and you get back something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;notAfter=Aug 14 12:00:00 2026 GMT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the raw expiry date straight from the live certificate your server is presenting to the world. Not what certbot thinks. Not what your local files say. What a browser actually sees when it connects.&lt;/p&gt;

&lt;p&gt;To turn that into days remaining:&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;EXPIRY&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; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; yoursite.com:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; yoursite.com &lt;span class="se"&gt;\&lt;/span&gt;
  2&amp;gt;/dev/null | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DAYS&lt;/span&gt;&lt;span class="s2"&gt; days remaining on SSL cert"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That arithmetic converts two Unix timestamps (the expiry date and now) to seconds, subtracts them, and divides by 86400 to get days. It's ugly but it works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three Non-Obvious Things I Ran Into
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The &lt;code&gt;-servername&lt;/code&gt; flag is not optional on SNI hosts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SNI — Server Name Indication — is how a single IP address serves multiple domains with different SSL certificates. Most shared hosting and most modern VPS setups use it. Without &lt;code&gt;-servername yoursite.com&lt;/code&gt;, openssl doesn't know which certificate to request. It reads the server's default certificate instead of yours.&lt;/p&gt;

&lt;p&gt;I spent twenty minutes getting clean results from this command before I realized I was checking the wrong cert. My server's default certificate was still valid. My production domain's certificate was the one expiring. I would have gotten a false "everything is fine" reading and missed the problem entirely. Always include &lt;code&gt;-servername&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The &lt;code&gt;echo |&lt;/code&gt; pipe is required for non-interactive use.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without it, &lt;code&gt;openssl s_client&lt;/code&gt; waits for stdin input after establishing the connection. In a cron job, that means the script hangs forever, waiting for input that never comes, holding the connection open until something kills it. The &lt;code&gt;echo&lt;/code&gt; sends empty input immediately, which closes the connection after the certificate handshake completes. This is one of those things where the command works perfectly in your terminal and completely silently breaks in cron. The &lt;code&gt;echo |&lt;/code&gt; is what makes it safe to automate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The &lt;code&gt;date -d&lt;/code&gt; syntax is Linux-only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;macOS uses &lt;code&gt;date -j -f "%b %d %T %Y %Z" "$EXPIRY" +%s&lt;/code&gt; instead of &lt;code&gt;date -d "$EXPIRY" +%s&lt;/code&gt;. If you're building a script that needs to run on both platforms, you'll need a conditional. The full version of the script at the link below handles this with an OS check. If you're only ever running this on Linux servers — which is probably most of you — you don't need to care about this.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;

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

&lt;span class="c"&gt;# --- Configuration ---&lt;/span&gt;
&lt;span class="nv"&gt;DOMAINS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
  &lt;span class="s2"&gt;"yourdomain.com"&lt;/span&gt;
  &lt;span class="s2"&gt;"anotherdomain.com"&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;WARN_DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30      &lt;span class="c"&gt;# Alert if fewer than this many days remain&lt;/span&gt;
&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;443

&lt;span class="c"&gt;# --- Check each domain ---&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;DOMAIN &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOMAINS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;EXPIRY&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; | openssl s_client &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOMAIN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    2&amp;gt;/dev/null | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&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="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; Could not retrieve cert for &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt; — is the server reachable?"&lt;/span&gt;
    &lt;span class="k"&gt;continue
  fi

  &lt;/span&gt;&lt;span class="nv"&gt;EXPIRY_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPIRY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;NOW_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;EXPIRY_EPOCH &lt;span class="o"&gt;-&lt;/span&gt; NOW_EPOCH&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&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;$DAYS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 0 &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt; — CERT EXPIRED &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DAYS&lt;/span&gt;&lt;span class="p"&gt;#-&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days ago"&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="o"&gt;[[&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;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WARN_DAYS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CROSS&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt; — WARNING: &lt;/span&gt;&lt;span class="nv"&gt;$DAYS&lt;/span&gt;&lt;span class="s2"&gt; days remaining (expires &lt;/span&gt;&lt;span class="nv"&gt;$EXPIRY&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CHECK&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt; — OK: &lt;/span&gt;&lt;span class="nv"&gt;$DAYS&lt;/span&gt;&lt;span class="s2"&gt; days remaining"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sample output with two domains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ yourdomain.com — OK: 74 days remaining
✗ anotherdomain.com — WARNING: 11 days remaining (expires Sep 2 12:00:00 2026 GMT)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why 30 Days as the Threshold
&lt;/h2&gt;

&lt;p&gt;Let's Encrypt certificates are 90 days. certbot typically renews at 60 days remaining — 30 days before the default renewal window. If my monitoring fires at 30 days, I have a full renewal cycle's worth of time to figure out whatever certbot is failing on before anything breaks.&lt;/p&gt;

&lt;p&gt;That's the math. 30 days is not conservative for its own sake — it's exactly one full renewal attempt window on a 90-day cert. If you're using a 1-year cert from a paid CA, you probably want 60 days. If you're on Let's Encrypt, 30 days gives you two full automated renewal attempts before you hit zero.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding an Email Alert
&lt;/h2&gt;

&lt;p&gt;If you want to be notified instead of (or in addition to) just logging, add this to the warning block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;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;$DAYS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WARN_DAYS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SSL cert for &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt; expires in &lt;/span&gt;&lt;span class="nv"&gt;$DAYS&lt;/span&gt;&lt;span class="s2"&gt; days"&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;"CERT EXPIRY WARNING: &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; you@youremail.com
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires &lt;code&gt;mailutils&lt;/code&gt; or &lt;code&gt;sendmail&lt;/code&gt; to be configured on your server. If you don't have email set up, the cron log version is enough — as long as you actually read the log, which is its own challenge.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cron Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 8 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /home/user/check-ssl-expiry.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/ssl-check.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every morning at 8am. Logs to &lt;code&gt;/var/log/ssl-check.log&lt;/code&gt;. If something fires, the line is there when you check in the morning. If everything is green, the log file is a quiet record that your certs are healthy.&lt;/p&gt;

&lt;p&gt;I run this across all my domains every day. 30-day warning threshold. On a Let's Encrypt setup that gives two full auto-renewal cycles before anything breaks. No more Monday morning surprises.&lt;/p&gt;

&lt;p&gt;The cert that expired on me was a $0 certificate on a $5 server. The cost wasn't the problem. The embarrassment of a user telling me before my own monitoring did — that was the part I wasn't willing to repeat.&lt;/p&gt;




&lt;p&gt;Full script with multi-domain array, configurable threshold, email alert variation, macOS compatibility, and cron setup instructions:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz/snippets/check-ssl-certificate-expiry" rel="noopener noreferrer"&gt;bashsnippets.xyz/snippets/check-ssl-certificate-expiry&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>set -euo pipefail — the Line That Would Have Saved Me from Deleting the Wrong Directory</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 03 Jun 2026 21:40:44 +0000</pubDate>
      <link>https://dev.to/bashsnippets/set-euo-pipefail-the-line-that-would-have-saved-me-from-deleting-the-wrong-directory-52k4</link>
      <guid>https://dev.to/bashsnippets/set-euo-pipefail-the-line-that-would-have-saved-me-from-deleting-the-wrong-directory-52k4</guid>
      <description>&lt;p&gt;Here's a script that will ruin your day:&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;cd&lt;/span&gt; /nonexistent/folder
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cd&lt;/code&gt; fails because the folder doesn't exist. Bash ignores the failure. &lt;code&gt;rm -rf *&lt;/code&gt; runs in whatever directory you were already in. The script prints "Done." You have no idea anything went wrong until you notice your files are gone.&lt;/p&gt;

&lt;p&gt;This isn't a contrived example. This is the actual failure mode of every bash script that doesn't have error handling. Bash's default behavior is to keep running after errors. Every command after a failure executes as if everything is fine. It's the most dangerous default in the entire language.&lt;/p&gt;

&lt;p&gt;Two lines fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Template
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'echo "Error on line $LINENO — script stopped." &amp;gt;&amp;amp;2'&lt;/span&gt; ERR

&lt;span class="c"&gt;# Your script starts here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add this to the top of every script you write. Every single one. No exceptions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Each Flag Does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;-e&lt;/code&gt; (errexit)&lt;/strong&gt; — Stop the script immediately if any command exits with a non-zero status. This is the one that prevents the &lt;code&gt;cd&lt;/code&gt; + &lt;code&gt;rm&lt;/code&gt; disaster above. If &lt;code&gt;cd&lt;/code&gt; fails, the script stops right there. &lt;code&gt;rm&lt;/code&gt; never runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;-u&lt;/code&gt; (nounset)&lt;/strong&gt; — Treat any undefined variable as an error. Without this, &lt;code&gt;$UNDEFINED_VAR&lt;/code&gt; silently becomes an empty string. Imagine this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;  &lt;span class="c"&gt;# Oops, forgot to set this&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That expands to &lt;code&gt;rm -rf /*&lt;/code&gt;. That's your entire filesystem. With &lt;code&gt;-u&lt;/code&gt;, bash catches the empty variable and stops before the &lt;code&gt;rm&lt;/code&gt; ever runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;-o pipefail&lt;/code&gt;&lt;/strong&gt; — Make a pipeline fail if ANY command in it fails, not just the last one. Without this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;nonexistent-file.txt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"pattern"&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cat&lt;/code&gt; fails, but &lt;code&gt;grep&lt;/code&gt; gets empty input, and &lt;code&gt;wc -l&lt;/code&gt; counts zero lines and exits successfully. The pipeline reports success even though the first command failed. With &lt;code&gt;pipefail&lt;/code&gt;, the pipeline's exit status is the failure from &lt;code&gt;cat&lt;/code&gt;, so &lt;code&gt;-e&lt;/code&gt; catches it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The &lt;code&gt;trap&lt;/code&gt; Line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'echo "Error on line $LINENO — script stopped." &amp;gt;&amp;amp;2'&lt;/span&gt; ERR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells bash: "When an error happens, before you exit, run this command." It prints which line number failed and writes to stderr (&lt;code&gt;&amp;gt;&amp;amp;2&lt;/code&gt;). Without this, the script just stops silently and you have to guess which line caused it.&lt;/p&gt;

&lt;p&gt;In a 200-line script, "it failed somewhere" is useless. "Error on line 47" is actionable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real-World Difference
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Without error handling:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/build
make
make &lt;span class="nb"&gt;install
echo&lt;/span&gt; &lt;span class="s2"&gt;"Build complete"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;cd&lt;/code&gt; fails (the directory doesn't exist), &lt;code&gt;make&lt;/code&gt; runs in the wrong directory. If &lt;code&gt;make&lt;/code&gt; fails (compilation error), &lt;code&gt;make install&lt;/code&gt; installs whatever was there from last time. The script prints "Build complete" regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With error handling:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'echo "Error on line $LINENO" &amp;gt;&amp;amp;2'&lt;/span&gt; ERR

&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/build
make
make &lt;span class="nb"&gt;install
echo&lt;/span&gt; &lt;span class="s2"&gt;"Build complete"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;cd&lt;/code&gt; fails, the script stops and prints "Error on line 5." If &lt;code&gt;make&lt;/code&gt; fails, same thing. &lt;code&gt;echo "Build complete"&lt;/code&gt; only runs if every single command before it succeeded.&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Gotcha
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;set -e&lt;/code&gt; changes how you write conditional logic. This fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"pattern"&lt;/span&gt; file.txt  &lt;span class="c"&gt;# exits 1 if pattern not found&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"This never runs"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;grep&lt;/code&gt; returns exit code 1 when it finds no matches. With &lt;code&gt;-e&lt;/code&gt;, that's treated as an error and the script stops. But you didn't want it to stop — you just wanted to check if the pattern exists.&lt;/p&gt;

&lt;p&gt;The fix is to use it in a conditional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"pattern"&lt;/span&gt; file.txt&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Found it"&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;"Not found"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a command is part of an &lt;code&gt;if&lt;/code&gt; condition, &lt;code&gt;-e&lt;/code&gt; doesn't trigger on its exit code. This is the standard pattern.&lt;/p&gt;

&lt;p&gt;Or suppress the exit code explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"pattern"&lt;/span&gt; file.txt &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; means "if grep fails, run &lt;code&gt;true&lt;/code&gt; instead" — and &lt;code&gt;true&lt;/code&gt; always succeeds, so &lt;code&gt;-e&lt;/code&gt; doesn't trigger.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to Go from Here
&lt;/h2&gt;

&lt;p&gt;Every script on &lt;a href="https://bashsnippets.xyz/snippets/" rel="noopener noreferrer"&gt;BashSnippets.xyz&lt;/a&gt; uses &lt;code&gt;set -euo pipefail&lt;/code&gt; and the &lt;code&gt;CHECK="✓"&lt;/code&gt; / &lt;code&gt;CROSS="✗"&lt;/code&gt; pattern. If you want a head start, the &lt;a href="https://bashsnippets.xyz/tools/bash-boilerplate-generator.html" rel="noopener noreferrer"&gt;Bash Boilerplate Generator&lt;/a&gt; builds a complete script template with error handling, logging, and cleanup traps already configured. Pick your options, copy the output, and start writing from a safe foundation.&lt;/p&gt;




&lt;p&gt;Full breakdown with more examples, the trap pattern, and common pitfalls:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bashsnippets.xyz/snippets/bash-error-handling.html" rel="noopener noreferrer"&gt;bashsnippets.xyz/snippets/bash-error-handling.html&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you only take one thing from this: add &lt;code&gt;set -euo pipefail&lt;/code&gt; to the top of every script. It takes 3 seconds and prevents the kind of failures that take hours to recover from.&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Aliased 'syscheck' to 7 Lines of Bash and Now I Run It on Every Server I SSH Into</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Mon, 01 Jun 2026 01:38:40 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-aliased-syscheck-to-7-lines-of-bash-and-now-i-run-it-on-every-server-i-ssh-into-1a8h</link>
      <guid>https://dev.to/bashsnippets/i-aliased-syscheck-to-7-lines-of-bash-and-now-i-run-it-on-every-server-i-ssh-into-1a8h</guid>
      <description>&lt;p&gt;Every time I SSH into a server, the first thing I want to know is: how's it doing?&lt;/p&gt;

&lt;p&gt;Not a deep dive. Not a monitoring dashboard. Just the basics: how long has it been running, how much RAM is left, is the disk getting full, what's the IP. Five things that take five separate commands to check — &lt;code&gt;hostname&lt;/code&gt;, &lt;code&gt;uptime&lt;/code&gt;, &lt;code&gt;free&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;, &lt;code&gt;hostname -I&lt;/code&gt; — and I'm tired of typing all five every single time.&lt;/p&gt;

&lt;p&gt;So I put them in one script and aliased it to &lt;code&gt;syscheck&lt;/code&gt;. Now I SSH in, type one word, and know the state of the machine in 2 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Quick System Info Report&lt;/span&gt;
&lt;span class="c"&gt;# Prints key stats at a glance.&lt;/span&gt;
&lt;span class="c"&gt;# Alias to 'syscheck' in your .bashrc for fast access.&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Quick System Check ==="&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Host    : &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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Uptime  : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;uptime&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"RAM     : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/Mem/{print $3"/"$2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Disk /  : &lt;/span&gt;&lt;span class="si"&gt;$(&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==2{print $3"/"$2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"IP      : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"========================="&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Quick System Check ===
Host    : my-server
Uptime  : up 3 days, 4 hours
RAM     : 1.2G/2.0G
Disk /  : 8.3G/25G
IP      : 192.168.1.42
=========================
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven lines. No dependencies. Every command used here ships pre-installed on every Linux distribution I've ever touched.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Each Line Actually Does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hostname&lt;/code&gt;&lt;/strong&gt; — prints the machine name. Obvious, but when you're managing 4 servers with different roles, seeing "staging-web" vs "prod-db" at the top saves you from running the wrong command on the wrong box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;uptime -p&lt;/code&gt;&lt;/strong&gt; — the &lt;code&gt;-p&lt;/code&gt; flag gives you human-readable output like "up 3 days, 4 hours" instead of the default &lt;code&gt;uptime&lt;/code&gt; output which includes load averages and the current time and is harder to parse at a glance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;free -h | awk '/Mem/{print $3"/"$2}'&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;free -h&lt;/code&gt; shows memory in human-readable format (GB instead of bytes). The &lt;code&gt;awk&lt;/code&gt; part grabs the "used" and "total" columns from the "Mem:" line and formats them as &lt;code&gt;1.2G/2.0G&lt;/code&gt;. You see at a glance whether you're at 60% RAM or 95%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;df -h / | awk 'NR==2{print $3"/"$2}'&lt;/code&gt;&lt;/strong&gt; — same idea but for disk. &lt;code&gt;df -h /&lt;/code&gt; checks the root partition, and &lt;code&gt;awk&lt;/code&gt; pulls used and total. If this says &lt;code&gt;23G/25G&lt;/code&gt; you know you need to clean up soon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hostname -I | awk '{print $1}'&lt;/code&gt;&lt;/strong&gt; — prints the machine's IP address. The &lt;code&gt;awk&lt;/code&gt; grabs just the first one in case there are multiple network interfaces.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Alias Setup (The Part That Makes It Worth It)
&lt;/h2&gt;

&lt;p&gt;The script is useful. The alias is what makes you actually use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nano ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add at the bottom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;syscheck&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'/home/user/syscheck.sh'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if you want the whole thing inline without a separate file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;syscheck&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'echo "=== Quick System Check ===" &amp;amp;&amp;amp; echo "Host    : $(hostname)" &amp;amp;&amp;amp; echo "Uptime  : $(uptime -p)" &amp;amp;&amp;amp; echo "RAM     : $(free -h | awk '&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;'/Mem/{print $3"/"$2}'&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;')" &amp;amp;&amp;amp; echo "Disk /  : $(df -h / | awk '&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;'NR==2{print $3"/"$2}'&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;')" &amp;amp;&amp;amp; echo "IP      : $(hostname -I | awk '&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="s2"&gt;"'"&lt;/span&gt;&lt;span class="s1"&gt;')" &amp;amp;&amp;amp; echo "========================="'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now type &lt;code&gt;syscheck&lt;/code&gt; on any terminal session on that machine and you get the full report instantly. I put this in &lt;code&gt;.bashrc&lt;/code&gt; on every server I set up. It's the first thing I configure after SSH key access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Extending It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Add CPU load:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CPU     : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;top &lt;span class="nt"&gt;-bn1&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Cpu(s)'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;% used"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Add the top 3 processes by memory:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Top RAM : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ps aux &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;-%mem | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;lt;=4{print $11}'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Add the OS version:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OS      : &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/os-release | &lt;span class="nb"&gt;grep &lt;/span&gt;PRETTY_NAME | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'"'&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep the base version minimal on purpose. When I need deeper visibility, I have separate scripts for &lt;a href="https://bashsnippets.xyz/snippets/monitor-cpu-ram-usage.html" rel="noopener noreferrer"&gt;CPU and RAM monitoring&lt;/a&gt; and &lt;a href="https://bashsnippets.xyz/snippets/disk-space-warning.html" rel="noopener noreferrer"&gt;disk space warnings&lt;/a&gt;. &lt;code&gt;syscheck&lt;/code&gt; is the quick glance. Those are the deep dive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where This Fits in the Workflow
&lt;/h2&gt;

&lt;p&gt;I SSH into a server. I type &lt;code&gt;syscheck&lt;/code&gt;. If everything looks normal, I do whatever I came to do. If RAM is at 95% or disk is nearly full, I investigate before touching anything else.&lt;/p&gt;

&lt;p&gt;It's the 2-second sanity check that prevents the "oh no, why is everything slow" surprise 20 minutes into a debugging session when you finally think to check resources and realize the disk filled up an hour ago.&lt;/p&gt;




&lt;p&gt;Full script, alias setup, and more extension ideas:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://bashsnippets.xyz/snippets/quick-system-info-report.html" rel="noopener noreferrer"&gt;bashsnippets.xyz/snippets/quick-system-info-report.html&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

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