<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged bash</title>
<link>https://coderlegion.com/tag/bash</link>
<description>Powered by Question2Answer</description>
<item>
<title>Finding the world-writable files on your server before someone else does</title>
<link>https://coderlegion.com/28028/finding-the-world-writable-files-on-your-server-before-someone-else-does</link>
<description>&lt;p&gt;A world-writable file can be rewritten by every account on the machine — including the service account your web server runs as. Code executing as &lt;code&gt;www-data&lt;/code&gt; through a compromised plugin doesn't need root: any &lt;code&gt;chmod 777&lt;/code&gt; you left behind during past debugging is a file it can overwrite or replace outright.&lt;/p&gt;
&lt;p&gt;Two details break most homegrown audits. First, &lt;code&gt;find -perm 777&lt;/code&gt; (no dash) matches mode 777 &lt;em&gt;exactly&lt;/em&gt; — a &lt;code&gt;666&lt;/code&gt; file is equally world-writable and slips through. The dash form uses mask semantics and catches every file with the other-write bit set. Second, never fix findings with &lt;code&gt;chmod -R 644&lt;/code&gt;: on directories, the execute bit means &quot;may be entered,&quot; and stripping it recursively seals off the whole tree. Split the fix by type instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find /var/www -type f -perm -o+w 2&amp;gt;/dev/null
find /var/www -type f -exec chmod 644 {} \;
find /var/www -type d -exec chmod 755 {} \;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SSH keys and secrets go tighter, to &lt;code&gt;600&lt;/code&gt; — OpenSSH refuses group- or world-readable private keys outright.&lt;/p&gt;
&lt;p&gt;I keep the longer version as a script with a saved report file, a SUID scan, and a recommended-modes reference: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/file-permissions-security&quot;&gt;full file-permissions security audit script&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28028/finding-the-world-writable-files-on-your-server-before-someone-else-does</guid>
<pubDate>Thu, 29 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>The log was deleted. Its 48M was still allocated.</title>
<link>https://coderlegion.com/30657/the-log-was-deleted-its-48m-was-still-allocated</link>
<description>&lt;p&gt;A 64M scratch tmpfs, a worker process holding its log open on file descriptor 3, and the usual reflex when space runs low: &lt;code&gt;rm&lt;/code&gt; the log. &lt;code&gt;du&lt;/code&gt; on the directory dropped from 48M to 0. &lt;code&gt;df&lt;/code&gt; stayed at 48M used. The next 32M write failed with &lt;code&gt;No space left on device&lt;/code&gt;, and &lt;code&gt;df&lt;/code&gt; read 100% while &lt;code&gt;du&lt;/code&gt; found only the 16M that had been written.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rm&lt;/code&gt; removes a name. The kernel frees the blocks only when no names are left and no process has the file open, and the worker still had it open. &lt;code&gt;lsof +L1&lt;/code&gt; lists exactly those files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ lsof -w +L1 /srv
COMMAND    PID USER FD   TYPE DEVICE SIZE/OFF NLINK NODE NAME
sleep   608657 root 3w   REG   0,78 50331648     0    3 /srv/app/worker.log (deleted)
exit=0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;NLINK 0&lt;/code&gt;, &lt;code&gt;(deleted)&lt;/code&gt;, 48M held for writing by PID 608657. Truncating through the process's own descriptor, &lt;code&gt;: &amp;gt; /proc/608657/fd/3&lt;/code&gt;, took &lt;code&gt;df&lt;/code&gt; back to 25% while the process kept running.&lt;/p&gt;
&lt;p&gt;Next time, empty a live log with &lt;code&gt;: &amp;gt; file&lt;/code&gt; instead of deleting it. With an append-mode writer (&lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt;) that is clean. A writer that opened with &lt;code&gt;&amp;gt;&lt;/code&gt; keeps its old offset: in the same test, its log came back as a 17M sparse file with 1.0M on disk.&lt;/p&gt;
&lt;p&gt;Finding which process holds a port or a busy mount, plus a script that groups deleted-but-open files and totals them, is on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/lsof-command-examples&quot;&gt;lsof command examples page&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30657/the-log-was-deleted-its-48m-was-still-allocated</guid>
<pubDate>Wed, 28 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>TruffleHog Secret Scanner Automation</title>
<link>https://coderlegion.com/32727/trufflehog-secret-scanner-automation</link>
<description>&lt;h4 id=&quot;context-and-problem&quot;&gt;Context and Problem&lt;/h4&gt;
&lt;p&gt;An AWS EKS Cluster was attacked, and its secrets were compromised. The Kubernetes Secrets were not encrypted, anyone who works with Kubernetes know they are only &lt;a rel=&quot;nofollow&quot; href=&quot;https://kubernetes.io/docs/concepts/configuration/secret/#working-with-secrets&quot;&gt;base64-encoded by default&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;task&quot;&gt;Task&lt;/h4&gt;
&lt;p&gt;I had a mission to scan 115 repositories across GitHub and Azure DevOps for secrets exposed anywhere in the Git history.&lt;/p&gt;
&lt;p&gt;The goal was simple: identify exposed secrets and provide actionable reports for the engineering team to fix them.&lt;/p&gt;
&lt;p&gt;A colleague recommended a tool called &lt;a rel=&quot;nofollow&quot; href=&quot;https://trufflesecurity.com/trufflehog&quot;&gt;TruffleHog&lt;/a&gt;, which became the foundation of the workflow.&lt;/p&gt;
&lt;h4 id=&quot;trufflehog&quot;&gt;TruffleHog&lt;/h4&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;TruffleHog is a secrets scanning tool that digs deep into your code repositories to find secrets, passwords, and sensitive keys.&quot;&lt;/p&gt;
&lt;p&gt;— TruffleHog official website&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 id=&quot;my-workflow&quot;&gt;My Workflow&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Read the TruffleHog documentation;&lt;/li&gt;
&lt;li&gt;Ran a test in a personal GitHub repository to see how the tool works;&lt;/li&gt;
&lt;li&gt;Check the total amount of repositories to scan, 115;&lt;/li&gt;
&lt;li&gt;Designed how I was going to complete the Task:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;- Decided to automate the process using Bash and jq.
- The script would receive the repository URL as a parameter.
- Used jq to filter and mask findings.
- Used jq to generate separate JSON files for active and inactive secrets.
- Generated standardized Notion reports to support remediation workflows.
- Used the generated JSON data with Gemini to create the reports.
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;report-example&quot;&gt;Report Example&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7tdrh77o38r464g7mra5.png&quot; alt=&quot;Report Example&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;lessons-learned&quot;&gt;Lessons Learned&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;- Standardizing reports made remediation much faster.
- Active and inactive secrets should be separated to improve prioritization.
- Automation becomes essential when dealing with dozens of repositories.
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-bash-and-jq-&quot;&gt;Why Bash and jq?&lt;/h4&gt;
&lt;p&gt;Keep the workflow simple and easy to run.&lt;/p&gt;
&lt;p&gt;jq made it easy to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;- filter findings
- mask sensitive values
- split active and inactive secrets
- generate structured outputs
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 id=&quot;check-the-code&quot;&gt;Check the code&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ja-lourenco/trufflehog-scan&quot;&gt;https://github.com/ja-lourenco/trufflehog-scan&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32727/trufflehog-secret-scanner-automation</guid>
<pubDate>Tue, 27 Oct 2026 16:34:53 +0000</pubDate>
</item>
<item>
<title>Three ways a jq one-liner lies to your bash script</title>
<link>https://coderlegion.com/28027/three-ways-a-jq-one-liner-lies-to-your-bash-script</link>
<description>&lt;p&gt;Most &quot;the filter looks right but the script misbehaves&quot; bugs come down to three silent failure modes.&lt;/p&gt;
&lt;p&gt;First, a missing &lt;code&gt;-r&lt;/code&gt;. jq outputs JSON by default, so a string arrives with its quotes attached — your variable holds &lt;code&gt;&quot;web-01&quot;&lt;/code&gt;, quotes included, and a comparison against web-01 never matches. Any value headed into a shell variable, filename, or test wants &lt;code&gt;-r&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Second, missing keys don't error. jq emits &lt;code&gt;null&lt;/code&gt;, which lands in bash as a literal four-character string — non-empty, so &lt;code&gt;[ -n ]&lt;/code&gt; guards pass and backup-null.tar.gz gets written. The &lt;code&gt;//&lt;/code&gt; operator supplies a real fallback.&lt;/p&gt;
&lt;p&gt;Third, select() compares exactly. A value that is almost right matches nothing, and nothing is not an error:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;jq -r '.items[] | select(.active == true) | .name'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the API sends &lt;code&gt;&quot;active&quot;: &quot;true&quot;&lt;/code&gt; as a string, that item is skipped. If you match &lt;code&gt;&quot;Web-01&quot;&lt;/code&gt; against &lt;code&gt;&quot;web-01&quot;&lt;/code&gt;, zero elements come back. Either way jq prints nothing and exits 0, so the silence reads as success.&lt;/p&gt;
&lt;p&gt;All three share the same fingerprint: exit code zero, plausible output, wrong result. The fastest way I've found to catch them before they ship is building the filter against the actual response: this &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/jq-filter-builder&quot;&gt;interactive jq filter builder with a live in-browser preview&lt;/a&gt; lets you click through your real JSON to build the path, handles the &lt;code&gt;-r&lt;/code&gt;, the &lt;code&gt;//&lt;/code&gt; fallback and select() quoting for you, and shows exactly what the filter emits before anything runs.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28027/three-ways-a-jq-one-liner-lies-to-your-bash-script</guid>
<pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>Limpando ~20 GB de caches de Terraform e Node.js com um único comando Bash</title>
<link>https://coderlegion.com/32582/limpando-20-gb-de-caches-de-terraform-e-node-js-com-um-unico-comando-bash</link>
<description>&lt;p&gt;Mantenho todos os meus repositórios em uma única pasta — cerca de 150, entre trabalho para clientes, templates e experimentos. Cada um deles é uma pequena armadilha: um &lt;code&gt;node_modules&lt;/code&gt; dentro de &lt;code&gt;app/&lt;/code&gt; e um &lt;code&gt;.terraform&lt;/code&gt; dentro de &lt;code&gt;infra/terraform/&lt;/code&gt;. Nenhum dos dois é versionado, ambos são reconstruídos em segundos (bem, minutos), e juntos tinham comido mais de 20 GB do meu disco sem que eu percebesse.&lt;/p&gt;
&lt;p&gt;O gatilho foi banal: &lt;code&gt;df -h&lt;/code&gt; mostrando 94% de uso numa partição de 233 GB, com 10 GB livres. A correção também deveria ser banal. Em vez de rodar um &lt;code&gt;find ... -exec rm -rf&lt;/code&gt; do qual eu me arrependeria, escrevi um comando pequeno em que eu pudesse confiar — e mantive.&lt;/p&gt;
&lt;p&gt;Este artigo traz o script, o raciocínio por trás de cada proteção e o que aprendi ao rodá-lo.&lt;/p&gt;
&lt;h3 id=&quot;o-objetivo&quot;&gt;O objetivo&lt;/h3&gt;
&lt;p&gt;Um comando, de qualquer lugar, que:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Varra &lt;code&gt;~/Documentos/github&lt;/code&gt; (em qualquer profundidade — incluindo monorepos e pastas agrupadas).&lt;/li&gt;
&lt;li&gt;Encontre &lt;strong&gt;apenas&lt;/strong&gt; dois tipos de diretório:&lt;ul&gt;
&lt;li&gt;&lt;code&gt;*/infra/terraform/.terraform&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;*/app/node_modules&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Informe o tamanho de cada um e os totais.&lt;/li&gt;
&lt;li&gt;Delete esses diretórios — mas somente quando isso for pedido explicitamente.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;decis-es-de-projeto&quot;&gt;Decisões de projeto&lt;/h3&gt;
&lt;p&gt;1. Dry-run como padrão: apagar cache de build é reversível, mas executar a operação em 30 diretórios distribuídos por 150 repositórios exige cautela. O parâmetro &lt;code&gt;--delete&lt;/code&gt; é obrigatório para efetivar as exclusões.&lt;/p&gt;
&lt;p&gt;2. &lt;code&gt;find -prune&lt;/code&gt; em vez de &lt;code&gt;du&lt;/code&gt; recursivo: evita percorrer diretórios como &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt; e &lt;code&gt;.terraform&lt;/code&gt;, mantendo a varredura eficiente mesmo com milhares de arquivos.&lt;/p&gt;
&lt;p&gt;3. Whitelist de caminhos antes de apagar: o &lt;code&gt;rm -rf&lt;/code&gt; só é executado em caminhos que correspondem exatamente aos sufixos permitidos e estão dentro da raiz varrida. Qualquer caminho fora dessas regras é ignorado e reportado.&lt;/p&gt;
&lt;p&gt;4. Medição de tamanho em paralelo: executa o &lt;code&gt;du&lt;/code&gt; em paralelo usando &lt;code&gt;xargs -P &quot;$(nproc)&quot;&lt;/code&gt;, aproveitando os núcleos disponíveis e reduzindo o tempo de execução em árvores grandes.&lt;/p&gt;
&lt;p&gt;5. Saída legível, cálculos precisos: os tamanhos são somados em bytes e formatados com &lt;code&gt;numfmt&lt;/code&gt;, evitando divergências causadas pela soma de valores arredondados.&lt;/p&gt;
&lt;p&gt;6. Espaço livre antes e depois: compara o espaço disponível no disco antes e depois da limpeza para verificar o ganho real obtido.&lt;/p&gt;
&lt;p&gt;Um detalhe que vale destacar: &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; é um &lt;strong&gt;arquivo&lt;/strong&gt; e &lt;em&gt;não&lt;/em&gt; é tocado. Ele fixa as versões dos providers e deve ser versionado. Apenas o diretório &lt;code&gt;.terraform/&lt;/code&gt; (cache de providers e módulos) é removido.&lt;/p&gt;
&lt;h3 id=&quot;o-script&quot;&gt;O script&lt;/h3&gt;
&lt;p&gt;Salve como &lt;code&gt;limpar-cache&lt;/code&gt;, dê permissão de execução e coloque em &lt;code&gt;/usr/local/bin&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
#
# limpar-cache — varre um workspace e levanta/remove:
#   * &amp;lt;projeto&amp;gt;/infra/terraform/.terraform
#   * &amp;lt;projeto&amp;gt;/app/node_modules
#
# Uso:
#   limpar-cache                 # só relatório (dry-run, nada é apagado)
#   limpar-cache --delete        # apaga, pedindo confirmação
#   limpar-cache --delete --yes  # apaga sem confirmação
#   limpar-cache --only node     # apenas node_modules
#   limpar-cache --only terraform
#   limpar-cache --base ~/outro/dir
#

set -uo pipefail

BASE=&quot;${HOME}/Documentos/github&quot;
ONLY=&quot;all&quot;
DO_DELETE=0
ASSUME_YES=0
JOBS=&quot;$(nproc 2&amp;gt;/dev/null || echo 4)&quot;

usage() {
  cat &amp;lt;&amp;lt;'EOF'
limpar-cache — relatório e limpeza de caches em ~/Documentos/github

Alvos:
  */infra/terraform/.terraform
  */app/node_modules

Opções:
  -d, --delete         remove os diretórios encontrados (com confirmação)
  -y, --yes            não pede confirmação (implica --delete)
  -n, --dry-run        apenas relatório (padrão)
      --only TIPO      all (padrão) | terraform | node
      --base DIR       raiz a varrer (padrão: ~/Documentos/github)
  -h, --help           esta ajuda

Exemplos:
  limpar-cache
  limpar-cache --only terraform
  limpar-cache -d -y
EOF
}

while (($#)); do
  case &quot;$1&quot; in
    -d | --delete) DO_DELETE=1 ;;
    -y | --yes) ASSUME_YES=1 ;;
    -n | --dry-run) DO_DELETE=0 ;;
    --only)
      ONLY=&quot;${2:-}&quot;
      shift
      ;;
    --base)
      BASE=&quot;${2:-}&quot;
      shift
      ;;
    -h | --help)
      usage
      exit 0
      ;;
    *)
      printf 'limpar-cache: opção inválida: %s\n\n' &quot;$1&quot; &amp;gt;&amp;amp;2
      usage &amp;gt;&amp;amp;2
      exit 2
      ;;
  esac
  shift
done

case &quot;$ONLY&quot; in
  all | terraform | node) ;;
  *)
    printf 'limpar-cache: --only deve ser all, terraform ou node\n' &amp;gt;&amp;amp;2
    exit 2
    ;;
esac

((ASSUME_YES)) &amp;amp;&amp;amp; DO_DELETE=1

if [[ ! -d &quot;$BASE&quot; ]]; then
  printf 'limpar-cache: diretório não encontrado: %s\n' &quot;$BASE&quot; &amp;gt;&amp;amp;2
  exit 1
fi

BASE=&quot;$(realpath -m -- &quot;$BASE&quot;)&quot;

human() { numfmt --to=iec-i --suffix=B --format='%.1f' &quot;$1&quot; 2&amp;gt;/dev/null || printf '%sB' &quot;$1&quot;; }

avail_bytes() { df -B1 --output=avail -- &quot;$BASE&quot; 2&amp;gt;/dev/null | tail -1 | tr -d ' '; }

# ---------------------------------------------------------------- coleta -----
# -prune evita descer em .git, node_modules e .terraform (rápido mesmo com 150 repos)
TF_DIRS=()
NODE_DIRS=()
while IFS= read -r -d '' d; do
  case &quot;$d&quot; in
    */infra/terraform/.terraform) TF_DIRS+=(&quot;$d&quot;) ;;
    */app/node_modules) NODE_DIRS+=(&quot;$d&quot;) ;;
  esac
done &amp;lt; &amp;lt;(find &quot;$BASE&quot; -mindepth 2 \( -name .git -o -name node_modules -o -name .terraform \) -prune -print0 2&amp;gt;/dev/null)

[[ &quot;$ONLY&quot; == &quot;node&quot; ]] &amp;amp;&amp;amp; TF_DIRS=()
[[ &quot;$ONLY&quot; == &quot;terraform&quot; ]] &amp;amp;&amp;amp; NODE_DIRS=()

if ((${#TF_DIRS[@]} == 0 &amp;amp;&amp;amp; ${#NODE_DIRS[@]} == 0)); then
  printf 'Nada encontrado em %s (filtro: %s).\n' &quot;$BASE&quot; &quot;$ONLY&quot;
  exit 0
fi

TMP=&quot;$(mktemp)&quot;
trap 'rm -f &quot;$TMP&quot;' EXIT

# tamanho aparente em paralelo: &quot;tipo&amp;lt;TAB&amp;gt;bytes&amp;lt;TAB&amp;gt;caminho&quot;
sizes_of() {
  local kind=&quot;$1&quot;
  shift
  (($#)) || return 0
  printf '%s\0' &quot;$@&quot; |
    xargs -0 -r -n1 -P &quot;$JOBS&quot; bash -c '
      p=&quot;$1&quot;
      s=&quot;$(du -sb -- &quot;$p&quot; 2&amp;gt;/dev/null | cut -f1)&quot;
      [[ -n &quot;$s&quot; ]] || s=0
      printf &quot;%s\t%s\t%s\n&quot; &quot;$0&quot; &quot;$s&quot; &quot;$p&quot;' &quot;$kind&quot;
}

{
  sizes_of terraform &quot;${TF_DIRS[@]+&quot;${TF_DIRS[@]}&quot;}&quot;
  sizes_of node &quot;${NODE_DIRS[@]+&quot;${NODE_DIRS[@]}&quot;}&quot;
} &amp;gt;&quot;$TMP&quot;

sort -t $'\t' -k2,2nr -o &quot;$TMP&quot; &quot;$TMP&quot;

total_tf=&quot;$(awk -F'\t' '$1==&quot;terraform&quot;{s+=$2} END{print s+0}' &quot;$TMP&quot;)&quot;
total_node=&quot;$(awk -F'\t' '$1==&quot;node&quot;{s+=$2} END{print s+0}' &quot;$TMP&quot;)&quot;
total_all=&quot;$((total_tf + total_node))&quot;
count_tf=&quot;${#TF_DIRS[@]}&quot;
count_node=&quot;${#NODE_DIRS[@]}&quot;

# ------------------------------------------------------------- relatório -----
printf '\nRaiz: %s\n' &quot;$BASE&quot;
printf '%-9s %12s  %s\n' &quot;TIPO&quot; &quot;TAMANHO&quot; &quot;CAMINHO&quot;
printf '%-9s %12s  %s\n' &quot;--------&quot; &quot;-----------&quot; &quot;----------------------------------------&quot;
while IFS=$'\t' read -r kind bytes path; do
  printf '%-9s %12s  %s\n' &quot;$kind&quot; &quot;$(human &quot;$bytes&quot;)&quot; &quot;${path#&quot;$BASE&quot;/}&quot;
done &amp;lt;&quot;$TMP&quot;

printf '\nResumo\n'
printf '  terraform/.terraform : %3d diretório(s)  %10s\n' &quot;$count_tf&quot; &quot;$(human &quot;$total_tf&quot;)&quot;
printf '  app/node_modules     : %3d diretório(s)  %10s\n' &quot;$count_node&quot; &quot;$(human &quot;$total_node&quot;)&quot;
printf '  TOTAL                : %3d diretório(s)  %10s\n' &quot;$((count_tf + count_node))&quot; &quot;$(human &quot;$total_all&quot;)&quot;
printf '  Livre em disco agora : %10s\n' &quot;$(human &quot;$(avail_bytes)&quot;)&quot;

if ((DO_DELETE == 0)); then
  printf '\nModo relatório (nada foi apagado). Para remover: %s --delete\n\n' &quot;$(basename &quot;$0&quot;)&quot;
  exit 0
fi

# --------------------------------------------------------------- remoção -----
if ((ASSUME_YES == 0)); then
  printf '\nRemover %d diretório(s), liberando ~%s? [s/N] ' &quot;$((count_tf + count_node))&quot; &quot;$(human &quot;$total_all&quot;)&quot;
  read -r reply
  case &quot;$reply&quot; in
    s | S | y | Y | sim | SIM) ;;
    *)
      printf 'Cancelado.\n\n'
      exit 1
      ;;
  esac
fi

is_safe() {
  local p=&quot;$1&quot; real
  [[ -n &quot;$p&quot; ]] || return 1
  [[ &quot;$p&quot; != &quot;/&quot; &amp;amp;&amp;amp; &quot;$p&quot; != &quot;$HOME&quot; ]] || return 1
  case &quot;$p&quot; in
    */infra/terraform/.terraform | */app/node_modules) ;;
    *) return 1 ;;
  esac
  real=&quot;$(realpath -m -- &quot;$p&quot; 2&amp;gt;/dev/null)&quot; || return 1
  [[ &quot;$real&quot; == &quot;$BASE&quot;/* ]] || return 1
  [[ &quot;$real&quot; != &quot;$BASE&quot; ]] || return 1
  return 0
}

avail_before=&quot;$(avail_bytes)&quot;
removed=0
failed=0

while IFS=$'\t' read -r kind bytes path; do
  if ! is_safe &quot;$path&quot;; then
    printf '  IGNORADO (fora do padrão/raiz): %s\n' &quot;$path&quot;
    ((failed++))
    continue
  fi
  if rm -rf -- &quot;$path&quot; 2&amp;gt;/dev/null &amp;amp;&amp;amp; [[ ! -e &quot;$path&quot; ]]; then
    printf '  removido  %10s  %s\n' &quot;$(human &quot;$bytes&quot;)&quot; &quot;${path#&quot;$BASE&quot;/}&quot;
    ((removed++))
  else
    printf '  FALHOU    %10s  %s\n' &quot;$(human &quot;$bytes&quot;)&quot; &quot;${path#&quot;$BASE&quot;/}&quot; &amp;gt;&amp;amp;2
    ((failed++))
  fi
done &amp;lt;&quot;$TMP&quot;

avail_after=&quot;$(avail_bytes)&quot;
freed=$((avail_after - avail_before))
((freed &amp;lt; 0)) &amp;amp;&amp;amp; freed=0

printf '\nConcluído: %d removido(s), %d falha(s).\n' &quot;$removed&quot; &quot;$failed&quot;
printf 'Espaço livre: %s -&amp;gt; %s (~%s liberados)\n\n' \
  &quot;$(human &quot;$avail_before&quot;)&quot; &quot;$(human &quot;$avail_after&quot;)&quot; &quot;$(human &quot;$freed&quot;)&quot;

((failed == 0)) || exit 1
exit 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instale e deixe disponível em todo shell:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;install -m 755 limpar-cache /usr/local/bin/limpar-cache
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;E no &lt;code&gt;~/.bashrc&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;export PATH=&quot;/usr/local/bin:$PATH&quot;
alias lcache='limpar-cache'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A linha explícita de &lt;code&gt;PATH&lt;/code&gt; é redundante na maioria das distribuições, mas não custa nada e deixa a configuração à prova de shells de login esquisitos. (Ela já me salvou uma vez — veja a última lição.)&lt;/p&gt;
&lt;h3 id=&quot;rodando&quot;&gt;Rodando&lt;/h3&gt;
&lt;p&gt;Relatório primeiro, sempre:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ lcache

Raiz: /home/user/Documentos/github
TIPO           TAMANHO  CAMINHO
--------   -----------  ----------------------------------------
node            1,4GiB  projeto-alfa/app/node_modules
node            1,1GiB  projeto-beta/app/node_modules
terraform     870,7MiB  projeto-alfa/infra/terraform/.terraform
terraform     865,2MiB  projeto-gama/infra/terraform/.terraform
node          771,2MiB  projeto-delta/app/node_modules
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Depois, quando a lista parecer certa:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;lcache --delete          # pergunta: Remover 30 diretório(s), liberando ~20,7GiB? [s/N]
lcache -d -y --only node # apenas node_modules, sem prompt
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Uma execução na minha máquina:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Resumo
  terraform/.terraform :   9 diretório(s)      7,1GiB
  app/node_modules     :  21 diretório(s)     13,6GiB
  TOTAL                :  30 diretório(s)     20,7GiB
  Livre em disco agora :      9,9GiB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;30 diretórios, ~20,7 GB. O disco saiu de 94% para cerca de 86% de uso — e cada um desses repositórios é reconstruível com &lt;code&gt;npm ci&lt;/code&gt; ou &lt;code&gt;terraform init&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;li-es-aprendidas&quot;&gt;Lições aprendidas&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;-prune&lt;/code&gt; é o truque inteiro.&lt;/strong&gt; Um &lt;code&gt;find ~/Documentos/github -name node_modules&lt;/code&gt; ingênuo desce em todo &lt;code&gt;node_modules&lt;/code&gt; procurando outros aninhados. Em 150 repositórios isso são minutos de I/O inútil. Podar no momento do match transforma tudo em uma única varredura rápida.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Tamanho aparente ≠ uso de disco.&lt;/strong&gt; &lt;code&gt;du -sb&lt;/code&gt; dá bytes aparentes (rápido, comparável), enquanto &lt;code&gt;du -s&lt;/code&gt; dá blocos alocados (o que o &lt;code&gt;df&lt;/code&gt; reporta). Para um relatório de limpeza, o tamanho aparente é o número honesto e previsível; para &quot;quanto eu vou ganhar de verdade&quot;, use &lt;code&gt;--block-size=1&lt;/code&gt; sem &lt;code&gt;-b&lt;/code&gt;. Eu mostro &lt;code&gt;df --output=avail&lt;/code&gt; antes/depois justamente porque os dois diferem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. O page cache frio domina o tempo de execução.&lt;/strong&gt; A primeira execução mediu ~2m26s; a segunda foi bem mais rápida. Nada de errado com o script — são 30 árvores de diretório sendo lidas do disco pela primeira vez. Paralelismo com &lt;code&gt;xargs -P&lt;/code&gt; ajuda, mas não vence a física.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Duas cópias de um script é um bug esperando para acontecer.&lt;/strong&gt; Eu tinha instalado em &lt;code&gt;~/.local/bin&lt;/code&gt; primeiro e depois &quot;movi&quot; para &lt;code&gt;/usr/local/bin&lt;/code&gt; — mas &lt;code&gt;~/.local/bin&lt;/code&gt; vem &lt;strong&gt;antes&lt;/strong&gt; no &lt;code&gt;PATH&lt;/code&gt;, então a cópia antiga sombreava a nova silenciosamente. Correção: &lt;em&gt;mover&lt;/em&gt; de verdade (&lt;code&gt;install&lt;/code&gt; + &lt;code&gt;rm&lt;/code&gt;), não copiar. E conferir com &lt;code&gt;command -v&lt;/code&gt;, não com &lt;code&gt;which&lt;/code&gt; (que é outro binário, com outra semântica).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Proteções são baratas; erros de &lt;code&gt;rm -rf&lt;/code&gt; não.&lt;/strong&gt; A função &lt;code&gt;is_safe()&lt;/code&gt; tem ~12 linhas e rejeita &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;$HOME&lt;/code&gt;, qualquer coisa fora da raiz varrida e qualquer caminho que não termine nos dois sufixos esperados. Ela roda em cada caminho, mesmo os vindos do meu próprio &lt;code&gt;find&lt;/code&gt;. Essa redundância é o ponto: o custo é microssegundos, o modo de falha é &quot;apaguei meu diretório home&quot;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Whitelist ganha de blacklist.&lt;/strong&gt; Eu deliberadamente &lt;em&gt;não&lt;/em&gt; apago &lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;.next/&lt;/code&gt;, &lt;code&gt;.venv/&lt;/code&gt; ou &lt;code&gt;coverage/&lt;/code&gt;. Esses têm custos de reconstrução muito diferentes e alguns são artefatos de deploy. Se eu precisar deles depois, cada um ganha seu sufixo explícito no &lt;code&gt;case&lt;/code&gt; — nunca uma regra de &quot;apague tudo que pareça cache&quot;.&lt;/p&gt;
&lt;h3 id=&quot;checklist-de-recupera-o&quot;&gt;Checklist de recuperação&lt;/h3&gt;
&lt;p&gt;Depois de uma limpeza dessas, cada repositório precisa de uma coisa:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Projetos Node
cd app &amp;amp;&amp;amp; npm ci          # ou pnpm install / yarn install

# Projetos Terraform
cd infra/terraform &amp;amp;&amp;amp; terraform init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;O &lt;code&gt;terraform init&lt;/code&gt; baixa os providers novamente; se você usa cache de plugins (&lt;code&gt;TF_PLUGIN_CACHE_DIR&lt;/code&gt;), será bem mais rápido da próxima vez — vale configurar se costuma limpar &lt;code&gt;.terraform&lt;/code&gt; com frequência.&lt;/p&gt;
&lt;h3 id=&quot;considera-es-finais&quot;&gt;Considerações finais&lt;/h3&gt;
&lt;p&gt;O script tem cerca de 200 linhas, a maioria delas &lt;code&gt;printf&lt;/code&gt;. O que o torna seguro não é sofisticação, e sim um punhado de escolhas entediantes: dry-run primeiro, podar cedo, whitelist de caminhos, confirmar antes de apagar e imprimir o que realmente aconteceu.&lt;/p&gt;
&lt;p&gt;Vinte gigabytes não é muito em termos absolutos, mas era 60% do espaço livre que eu tinha. Mais importante: o comando agora existe. Na próxima vez que o disco apertar, é uma linha em vez de uma tarde de &lt;code&gt;du&lt;/code&gt; e dúvidas.&lt;/p&gt;
&lt;p&gt;Se você mantém muitos repositórios num só lugar, o padrão se generaliza — troque os dois sufixos pelos seus próprios artefatos de build, mantenha o dry-run como padrão e nunca deixe o script adivinhar o que deve apagar.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32582/limpando-20-gb-de-caches-de-terraform-e-node-js-com-um-unico-comando-bash</guid>
<pubDate>Mon, 26 Oct 2026 18:03:24 +0000</pubDate>
</item>
<item>
<title>Why grep &quot;error|failed&quot; finds nothing in a log full of failures</title>
<link>https://coderlegion.com/28026/why-grep-error-failed-finds-nothing-in-a-log-full-of-failures</link>
<description>&lt;p&gt;If grep returns empty on a file you know contains matches, suspect the pattern before the file. Default grep uses Basic Regular Expressions (BRE), where &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, and &lt;code&gt;()&lt;/code&gt; are literal characters unless backslash-escaped. So &lt;code&gt;grep &quot;error|failed&quot; app.log&lt;/code&gt; searches for the literal twelve-character string &lt;code&gt;error|failed&lt;/code&gt; — pipe included — which no logger ever writes. Alternation needs Extended Regular Expressions via &lt;code&gt;-E&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep &quot;error|failed&quot; app.log     # BRE: literal string, matches nothing
grep -E &quot;error|failed&quot; app.log  # ERE: matches either word
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dangerous part is the exit behavior. An impossible pattern is not a syntax error, so grep exits 1 — the same code as &quot;no matches in a clean file&quot; — and prints nothing. A wrong question and a healthy log look identical, which is how this bug hides inside monitoring checks and cron pipelines for days.&lt;/p&gt;
&lt;p&gt;Two related traps: &lt;code&gt;-P&lt;/code&gt; (Perl regex, &lt;code&gt;\d&lt;/code&gt;, lookaheads) is GNU-only and fails on macOS/BSD grep, and bare grep aimed at a directory refuses with &quot;Is a directory&quot; unless &lt;code&gt;-r&lt;/code&gt; is set.&lt;/p&gt;
&lt;p&gt;When I'm unsure what a pattern will match, I assemble the command in this &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/grep-pattern-builder&quot;&gt;grep command builder with a live match tester&lt;/a&gt; — paste a sample line you know should match, and a zero count tells you the pattern is the bug before the empty result gets believed.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28026/why-grep-error-failed-finds-nothing-in-a-log-full-of-failures</guid>
<pubDate>Thu, 22 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>bash ran a path that no longer existed, and exited 127</title>
<link>https://coderlegion.com/30653/bash-ran-a-path-that-no-longer-existed-and-exited-127</link>
<description>&lt;p&gt;A demo script called &lt;code&gt;report&lt;/code&gt;, two directories on &lt;code&gt;PATH&lt;/code&gt;, and one &lt;code&gt;mv&lt;/code&gt; between them, all on a scratch tmpfs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ report
report 2.1: 14 jobs ok
exit=0
$ mv /opt/jobs/bin/report /opt/jobs/newbin/report
$ ls -l /opt/jobs/newbin/report
-rwxrwxr-x 1 root root 42 Oct  3 18:26 /opt/jobs/newbin/report
$ report
session.sh: line 5: /opt/jobs/bin/report: No such file or directory
exit=127
$ type -a report
report is /opt/jobs/newbin/report
$ hash -t report
/opt/jobs/bin/report
$ command -v report
/opt/jobs/bin/report
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The file is there, on &lt;code&gt;PATH&lt;/code&gt;, and bash ran the old path anyway. The first time it finds a command through a &lt;code&gt;PATH&lt;/code&gt; search, it stores the full path in a hash table and skips the search after that. When the file moves, it executes the remembered path and reports &lt;code&gt;No such file or directory&lt;/code&gt; with exit 127, the same code a typo gets.&lt;/p&gt;
&lt;p&gt;The confusing part is the end of that output. &lt;code&gt;type -a&lt;/code&gt; searches &lt;code&gt;PATH&lt;/code&gt; fresh and finds the new location. &lt;code&gt;command -v&lt;/code&gt; reports what bash would run, which is the stale one. &lt;code&gt;hash -r&lt;/code&gt; clears the table, a new terminal starts empty, and &lt;code&gt;shopt -s checkhash&lt;/code&gt; makes bash re-search when a hashed path has gone: with it set, the same sequence printed the tool's output and exit 0.&lt;/p&gt;
&lt;p&gt;The stale hash is one of six causes behind &lt;code&gt;command not found&lt;/code&gt;. The others, from a cron &lt;code&gt;PATH&lt;/code&gt; to a CRLF shebang, each reproduced with a script that names the cause, are in &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-command-not-found&quot;&gt;command not found in bash, six causes&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30653/bash-ran-a-path-that-no-longer-existed-and-exited-127</guid>
<pubDate>Wed, 21 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>rsync's dangerous flags don't fail. They do a different job and exit 0.</title>
<link>https://coderlegion.com/28025/rsyncs-dangerous-flags-dont-fail-they-do-a-different-job-and-exit-0</link>
<description>&lt;p&gt;The trap with rsync is that its dangerous flag combinations don't fail — they complete a different operation than the one you intended, and exit 0. &lt;code&gt;--delete&lt;/code&gt; makes the destination match the source, so a mistyped or freshly moved source directory means rsync removes everything at the destination and reports success. Your cron log shows a clean run while the backup empties itself. A trailing slash changes the meaning too: &lt;code&gt;/data/&lt;/code&gt; syncs the directory's contents, while &lt;code&gt;/data&lt;/code&gt; copies the directory itself into the destination — and on a mirror job, that difference decides what gets compared and what gets deleted.&lt;/p&gt;
&lt;p&gt;The defense is previewing every destructive sync before running it live:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rsync -av --delete --dry-run /srv/www/ user@backup:/srv/backup/www/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read the &lt;code&gt;deleting …&lt;/code&gt; lines it prints; only when that list matches your expectations do you remove &lt;code&gt;--dry-run&lt;/code&gt;. Note also that excludes take one pattern per flag — &lt;code&gt;--exclude='*.log' --exclude='.git'&lt;/code&gt; — because a comma-separated list inside a single flag silently matches nothing.&lt;/p&gt;
&lt;p&gt;If you'd rather compose the command with those guardrails enforced — a red warning when &lt;code&gt;--delete&lt;/code&gt; is enabled without a dry run, per-pattern quoted excludes, and a live preview of the finished command — I built a &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/rsync-command-builder&quot;&gt;visual rsync command builder with delete-flag guardrails&lt;/a&gt; that runs entirely in the browser.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28025/rsyncs-dangerous-flags-dont-fail-they-do-a-different-job-and-exit-0</guid>
<pubDate>Tue, 20 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>One Bash Script to Audit a Polyglot Codebase (Python, TS, Java, Go, C, HTML)</title>
<link>https://coderlegion.com/31711/one-bash-script-to-audit-a-polyglot-codebase-python-ts-java-go-c-html</link>
<description>&lt;p&gt;If your project mixes languages, one terminal command can run each language's own tests and security checks and fail the build when anything is red.&lt;/p&gt;
&lt;p&gt;The script (1) counts files per language, (2) runs build and type checks, (3) runs tests and SAST, (4) scans dependencies and secrets, (5) runs a quick concurrency check, and (6) writes a checksummed report.&lt;/p&gt;
&lt;p&gt;I tested it on a small demo project: python compile passed, missing scanners showed as SKIP, an invalid HTML file produced a real FAIL, and the run exited with code 1.&lt;/p&gt;
&lt;p&gt;Full guide with per-language settings, a troubleshooting table and a CI example: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.seosiri.com/2026/10/polyglot-code-audit-bash-testing-pipeline.html#conclusion&quot;&gt;https://www.seosiri.com/2026/10/polyglot-code-audit-bash-testing-pipeline.html#conclusion&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Which scanner would you add first?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31711/one-bash-script-to-audit-a-polyglot-codebase-python-ts-java-go-c-html</guid>
<pubDate>Mon, 19 Oct 2026 11:43:05 +0000</pubDate>
</item>
<item>
<title>Every disk-alert script I had piped into a command this machine does not have</title>
<link>https://coderlegion.com/28024/every-disk-alert-script-i-had-piped-into-a-command-this-machine-does-not-have</link>
<description>&lt;p&gt;The standard bash alerting pattern ends the same way everywhere you read it: pipe the finding into &lt;code&gt;mail&lt;/code&gt;. So I checked what my own box would actually do with that line.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ type -a mail mailx sendmail
mail not found
mailx not found
sendmail not found
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing. No MTA, no &lt;code&gt;mailutils&lt;/code&gt;, no fallback. Any alert script written the usual way would have run its check correctly, found the problem correctly, piped the message into a command that does not exist, and told me nothing.&lt;/p&gt;
&lt;p&gt;This fails quieter than it sounds. Under &lt;code&gt;set -euo pipefail&lt;/code&gt; you at least get a non-zero exit somewhere a cron wrapper might notice. Without it, the pipeline's exit status is the &lt;em&gt;last&lt;/em&gt; command's, cron mails you nothing because cron's mail also needs an MTA, and the script looks like it ran fine.&lt;/p&gt;
&lt;p&gt;Check for the binary before you rely on it, not after an incident. And on a box that &lt;em&gt;does&lt;/em&gt; have &lt;code&gt;mail&lt;/code&gt;, exit 0 still only means the message reached a local MTA — on a host with no outbound relay it lands in a spool file under &lt;code&gt;/var/mail&lt;/code&gt; that nobody reads. Confirm real delivery once by tailing &lt;code&gt;/var/log/mail.log&lt;/code&gt;. Cron's minimal &lt;code&gt;PATH&lt;/code&gt; is a second reason to test from inside cron rather than your login shell.&lt;/p&gt;
&lt;p&gt;The msmtp relay config and the rate-limiting guard are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-send-email-alert&quot;&gt;bash email alert walkthrough&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28024/every-disk-alert-script-i-had-piped-into-a-command-this-machine-does-not-have</guid>
<pubDate>Thu, 15 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>One empty variable, one error, one wrong answer, exit 0</title>
<link>https://coderlegion.com/29670/one-empty-variable-one-error-one-wrong-answer-exit-0</link>
<description>&lt;p&gt;I wrote four lines to capture a bash error for a guide. One empty variable, tested twice:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ cat queue-check.sh
#!/bin/bash
count=&quot;&quot;
if [ $count -eq 0 ]; then echo &quot;queue empty&quot;; fi
if [[ $count -eq 0 ]]; then echo &quot;queue empty ([[ ]])&quot;; fi

$ bash queue-check.sh
queue-check.sh: line 3: [: -eq: unary operator expected
queue empty ([[ ]])
exit=0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The message blames &lt;code&gt;-eq&lt;/code&gt;. The bug is the unquoted &lt;code&gt;$count&lt;/code&gt; before it: word splitting removed the empty value, so &lt;code&gt;[&lt;/code&gt; received &lt;code&gt;-eq 0&lt;/code&gt; and failed with status 2, which an &lt;code&gt;if&lt;/code&gt; treats as &quot;not true&quot;. The &lt;code&gt;[[ ]]&lt;/code&gt; line does no splitting, read the empty string as 0 in an arithmetic test, and printed a confident wrong answer. Then the script exited 0.&lt;/p&gt;
&lt;p&gt;Strict mode does not help here. &lt;code&gt;count=&quot;&quot;&lt;/code&gt; is set, so &lt;code&gt;set -u&lt;/code&gt; stays quiet, and &lt;code&gt;set -e&lt;/code&gt; ignores failures inside an &lt;code&gt;if&lt;/code&gt; condition. What stopped it was validating the value first: &lt;code&gt;[[ $count =~ ^[0-9]+$ ]] || exit 1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Most bash errors work this way. The message names the check that failed, not the cause: a syntax error reported on line 4 for a missing semicolon on line 2, &lt;code&gt;Permission denied&lt;/code&gt; after &lt;code&gt;chmod +x&lt;/code&gt; on a &lt;code&gt;noexec&lt;/code&gt; mount. Nine of them, each reproduced with the check that names the cause, are in &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-error-messages&quot;&gt;Bash Error Messages, Decoded&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29670/one-empty-variable-one-error-one-wrong-answer-exit-0</guid>
<pubDate>Wed, 14 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>I escaped the dot correctly and sed still rewrote a file marked DO NOT CHANGE</title>
<link>https://coderlegion.com/28023/i-escaped-the-dot-correctly-and-sed-still-rewrote-a-file-marked-do-not-change</link>
<description>&lt;p&gt;Four config files, one hostname to rename. I knew the dot in &lt;code&gt;api.internal&lt;/code&gt; is a regex wildcard, so I escaped it. &lt;code&gt;grep -rn 'api.internal'&lt;/code&gt; unescaped matched a fourth file containing &lt;code&gt;apiXinternal&lt;/code&gt; — a typo somebody deliberately kept. Escaped, &lt;code&gt;api\.internal&lt;/code&gt; matched three. Good.&lt;/p&gt;
&lt;p&gt;Then I ran the rename, and two of those three should never have changed: a line ending in &lt;code&gt;# DO NOT CHANGE&lt;/code&gt;, and a commented-out sample. My &lt;code&gt;-not -path &quot;*/samples/*&quot;&lt;/code&gt; exclusion did nothing, because the file was called &lt;code&gt;sample.conf&lt;/code&gt; and was not inside a &lt;code&gt;samples/&lt;/code&gt; directory.&lt;/p&gt;
&lt;p&gt;That is the real lesson, and escaping was never the whole answer. The dot is the trap everyone warns about; the trap that actually got me was assuming my exclusion pattern matched what I pictured. The fix is an order, not a flag — make &lt;code&gt;grep -l&lt;/code&gt; print the file list while everything is still read-only, and read it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find /etc/myapp -type f -name &quot;*.conf&quot; -exec grep -l &quot;api\.internal&quot; {} +
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If that list surprises you, the surprise &lt;em&gt;is&lt;/em&gt; the bug, and you found it before anything was written. Only then pipe it to &lt;code&gt;sed -i.bak&lt;/code&gt;, so every file keeps a rollback copy — and diff one against its &lt;code&gt;.bak&lt;/code&gt; before trusting the run. &lt;code&gt;sed -i&lt;/code&gt; exits 0 whether it changed four files or none.&lt;/p&gt;
&lt;p&gt;The awk summarizing stage and the incident-triage pipeline are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-text-processing&quot;&gt;find, grep, sed and awk ordering guide&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28023/i-escaped-the-dot-correctly-and-sed-still-rewrote-a-file-marked-do-not-change</guid>
<pubDate>Tue, 13 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>My 30-day log cleanup kept a file that was 30 and a half days old</title>
<link>https://coderlegion.com/28022/my-30-day-log-cleanup-kept-a-file-that-was-30-and-a-half-days-old</link>
<description>&lt;p&gt;I set three log files to known ages with &lt;code&gt;touch -d&lt;/code&gt; — exactly 30 days, 30 days and 12 hours, and 31 days — then ran what I thought was a 30-day retention policy:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find . -name '*.log' -mtime +30
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It returned one file: the 31-day-old one. The 30.5-day file stayed. So did the 30-day file.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;-mtime&lt;/code&gt; counts &lt;em&gt;whole&lt;/em&gt; 24-hour periods and throws away the remainder, and &lt;code&gt;+30&lt;/code&gt; means strictly greater than 30 of them. A file aged 30 days and 12 hours counts as 30, and 30 is not greater than 30. A real 30-day policy is &lt;code&gt;-mtime +29&lt;/code&gt;. Re-running with &lt;code&gt;+29&lt;/code&gt; returned all three, which is what I wanted in the first place.&lt;/p&gt;
&lt;p&gt;Two more things that bit during the same session. Piping to &lt;code&gt;xargs rm&lt;/code&gt; split &lt;code&gt;app v2.log&lt;/code&gt; on the space — &lt;code&gt;rm&lt;/code&gt; reported &lt;code&gt;cannot remove 'ws/app'&lt;/code&gt; and &lt;code&gt;cannot remove 'v2.log'&lt;/code&gt;, and the file survived. And &lt;code&gt;-delete&lt;/code&gt; is an action that returns true, so putting it before your filter is catastrophic: &lt;code&gt;find ord -delete -name '*.log'&lt;/code&gt; removed a file called &lt;code&gt;keep.txt&lt;/code&gt; and then the directory itself. Preview with &lt;code&gt;-print&lt;/code&gt;, keep &lt;code&gt;-delete&lt;/code&gt; last.&lt;/p&gt;
&lt;p&gt;The multi-directory loop, the &lt;code&gt;.gz&lt;/code&gt; variant, and the cron lines are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/delete-old-log-files&quot;&gt;find -mtime log retention walkthrough&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28022/my-30-day-log-cleanup-kept-a-file-that-was-30-and-a-half-days-old</guid>
<pubDate>Thu, 08 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>Catch a filling disk weeks before it becomes an outage</title>
<link>https://coderlegion.com/28021/catch-a-filling-disk-weeks-before-it-becomes-an-outage</link>
<description>&lt;p&gt;A disk almost never fails loudly. It fills a percent at a time until writes stop, and at 100% nothing reports &quot;disk full&quot; — the database throws write errors about its own files, apps crash-loop, and logging (a write itself) stops recording at the exact moment you need evidence. You end up debugging five fake problems instead of one real one.&lt;/p&gt;
&lt;p&gt;The prevention costs one line on a daily cron:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;df -h | awk 'NR&amp;gt;1 &amp;amp;&amp;amp; $5+0 &amp;gt;= 80 {print $0}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;NR&amp;gt;1&lt;/code&gt; skips the header. &lt;code&gt;$5+0&lt;/code&gt; is the trick: df's Use% column is a string like &lt;code&gt;82%&lt;/code&gt;, and awk's numeric coercion keeps the leading digits and drops the &lt;code&gt;%&lt;/code&gt;, so the threshold comparison works without cut or sed. Pipe any output to &lt;code&gt;mail -s&lt;/code&gt; and you get an email the day a filesystem crosses 80.&lt;/p&gt;
&lt;p&gt;Why 80? At 80% a disk growing one percent a day leaves you weeks to investigate with &lt;code&gt;du -ah | sort -rh | head -20&lt;/code&gt;. At 95% you're deleting files under pressure; at 100% every service on the box misbehaves at once.&lt;/p&gt;
&lt;p&gt;This check is #1 in a set of &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripts-every-sysadmin-needs&quot;&gt;25 cron-ready bash scripts for sysadmins covering disk monitoring, backups, service watchdogs, SSL expiry, and port audits&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28021/catch-a-filling-disk-weeks-before-it-becomes-an-outage</guid>
<pubDate>Tue, 06 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>sed 's/port/listen_port/g' rewrote four lines of a four-line config</title>
<link>https://coderlegion.com/28020/sed-s-port-listenport-g-rewrote-four-lines-of-a-four-line-config</link>
<description>&lt;p&gt;A four-line YAML file (&lt;code&gt;port: 8080&lt;/code&gt;, &lt;code&gt;support_email&lt;/code&gt;, &lt;code&gt;transport: tcp&lt;/code&gt;, &lt;code&gt;report_dir: /var/reports&lt;/code&gt;) and the rename most people type first: &lt;code&gt;sed -i 's/port/listen_port/g' app.yml&lt;/code&gt;. GNU sed 4.9 exited 0 and printed nothing, because &lt;code&gt;-i&lt;/code&gt; never does. Afterwards the file held &lt;code&gt;suplisten_port_email&lt;/code&gt;, &lt;code&gt;translisten_port&lt;/code&gt; and &lt;code&gt;/var/relisten_ports&lt;/code&gt;. One line was meant to change. All four did.&lt;/p&gt;
&lt;p&gt;sed matches characters, not words. The letters p-o-r-t sit inside &lt;code&gt;support&lt;/code&gt;, &lt;code&gt;transport&lt;/code&gt; and &lt;code&gt;report&lt;/code&gt;, and &lt;code&gt;g&lt;/code&gt; replaced every occurrence. GNU sed's &lt;code&gt;\b&lt;/code&gt; word boundary fixes the match, and one pipe shows the damage before anything is written:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sed 's/\bport\b/listen_port/g' app.yml | diff app.yml -
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without &lt;code&gt;-i&lt;/code&gt; the file on disk stays intact, and &lt;code&gt;diff&lt;/code&gt; prints the lines that would change. It exits 1 when it finds differences, so the same check works inside a script. Only after the diff reads right does &lt;code&gt;-i&lt;/code&gt; go back on.&lt;/p&gt;
&lt;p&gt;Two caveats before trusting &lt;code&gt;\b&lt;/code&gt;. Underscore counts as a word character, so &lt;code&gt;db_port&lt;/code&gt; stays untouched. And &lt;code&gt;\b&lt;/code&gt; is a GNU extension: BSD sed on macOS spells word boundaries &lt;code&gt;[[:&amp;lt;:]]&lt;/code&gt; and &lt;code&gt;[[:&amp;gt;:]]&lt;/code&gt;, and its &lt;code&gt;-i&lt;/code&gt; requires a suffix argument (&lt;code&gt;sed -i ''&lt;/code&gt;) where GNU's is optional.&lt;/p&gt;
&lt;p&gt;The replacement side bites too. An unescaped &lt;code&gt;&amp;amp;&lt;/code&gt; means the whole match, so &lt;code&gt;s/CEO/Smith &amp;amp; Sons/&lt;/code&gt; produces &lt;code&gt;Smith CEO Sons&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The bulk-replace script, with a &lt;code&gt;grep -rl&lt;/code&gt; file list, per-file diff and an &lt;code&gt;--apply&lt;/code&gt; gate that fails toward reading, is on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-sed-find-replace&quot;&gt;sed find-and-replace snippet page&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28020/sed-s-port-listenport-g-rewrote-four-lines-of-a-four-line-config</guid>
<pubDate>Thu, 01 Oct 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>ShellCheck rates SC2086 as info. It let rm delete two files I never named.</title>
<link>https://coderlegion.com/28019/shellcheck-rates-sc2086-as-info-it-let-rm-delete-two-files-i-never-named</link>
<description>&lt;p&gt;SC2086 sits at info, ShellCheck's lowest default severity, and fires on every unquoted &lt;code&gt;$var&lt;/code&gt;, so it is usually the first rule anyone disables. I built the smallest test I could: a directory holding &lt;code&gt;quarterly&lt;/code&gt;, &lt;code&gt;report.txt&lt;/code&gt; and &lt;code&gt;quarterly report.txt&lt;/code&gt;, and a four-line script under &lt;code&gt;set -euo pipefail&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;report=&quot;quarterly report.txt&quot;
rm $report
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It deleted &lt;code&gt;quarterly&lt;/code&gt;. It deleted &lt;code&gt;report.txt&lt;/code&gt;. It left the one file it was asked to remove, and exited 0. rm was handed two names and both existed, so strict mode had nothing to catch.&lt;/p&gt;
&lt;p&gt;Bash expands the unquoted variable, splits the result on whitespace, globs any &lt;code&gt;*&lt;/code&gt; or &lt;code&gt;?&lt;/code&gt; against the current directory, and only then builds the argument list. &lt;code&gt;rm &quot;$report&quot;&lt;/code&gt; is one argument with no globbing, and it deletes the right file.&lt;/p&gt;
&lt;p&gt;The same bug hides in other shapes. &lt;code&gt;[ $name = &quot;root&quot; ]&lt;/code&gt; with an empty &lt;code&gt;$name&lt;/code&gt; gives &lt;code&gt;test&lt;/code&gt; two arguments: a complaint on stderr, a false condition, and the script keeps walking. &lt;code&gt;for f in $files&lt;/code&gt; is not flagged by ShellCheck 0.11.0 at all, and still breaks on the first name with a space.&lt;/p&gt;
&lt;p&gt;When you do want the split, such as a flag string read from a config, use an array or a one-line &lt;code&gt;# shellcheck disable=SC2086&lt;/code&gt; with the reason. A repo-wide &lt;code&gt;.shellcheckrc&lt;/code&gt; disable covers every future &lt;code&gt;rm&lt;/code&gt; as well. And &lt;code&gt;shellcheck --include=SC2086&lt;/code&gt; runs it alone as a CI gate.&lt;/p&gt;
&lt;p&gt;Every run, pasted from ShellCheck 0.11.0, is in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/shellcheck/sc2086&quot;&gt;SC2086 deep dive on word splitting and globbing&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28019/shellcheck-rates-sc2086-as-info-it-let-rm-delete-two-files-i-never-named</guid>
<pubDate>Tue, 29 Sep 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>I sent my report script SIGTERM mid-write. Yesterday's output survived intact.</title>
<link>https://coderlegion.com/28018/i-sent-my-report-script-sigterm-mid-write-yesterdays-output-survived-intact</link>
<description>&lt;p&gt;A script that writes straight to the path a consumer reads can die halfway and leave a torn file that everything downstream loads as complete. No error appears anywhere, because the only process that failed is gone. I tested the fix the unfriendly way on bash 5.3.9: a CSV generator with a &lt;code&gt;sleep 30&lt;/code&gt; planted between rows, killed with SIGTERM one second in.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;TMP_FILE=$(mktemp)
cleanup() { local code=$?; rm -f &quot;$TMP_FILE&quot;; exit &quot;$code&quot;; }
trap cleanup EXIT
generate_rows &amp;gt; &quot;$TMP_FILE&quot;
mv &quot;$TMP_FILE&quot; &quot;$FINAL_PATH&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exit 143, which is 128 plus signal 15. The temp file was gone, and &lt;code&gt;out.csv&lt;/code&gt; still held the previous run's three complete lines. Stale is something monitoring can see. Torn is not.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;EXIT&lt;/code&gt; fires on normal completion, an explicit &lt;code&gt;exit 1&lt;/code&gt;, a &lt;code&gt;set -e&lt;/code&gt; abort and after a handled signal, which is why this is five lines instead of twenty. And because &lt;code&gt;mv&lt;/code&gt; on one filesystem is atomic, being half-written and being at the published path are never true at once. Even &lt;code&gt;kill -9&lt;/code&gt;, which no trap catches, cannot publish a torn file.&lt;/p&gt;
&lt;p&gt;The ordering is the part people move. I ran four handler variants against a script ending in &lt;code&gt;exit 3&lt;/code&gt;, and only one exited 0: &lt;code&gt;rm -f&lt;/code&gt; first, then &lt;code&gt;local code=$?&lt;/code&gt;. It captured rm's success and laundered the failure. Capture first, clean second, exit last.&lt;/p&gt;
&lt;p&gt;The complete script, subshell scoping rules and a line-by-line breakdown are on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-trap-cleanup&quot;&gt;trap cleanup page for temp files and atomic writes&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28018/i-sent-my-report-script-sigterm-mid-write-yesterdays-output-survived-intact</guid>
<pubDate>Thu, 24 Sep 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>My ERR trap stayed silent until I added one letter to set -euo pipefail</title>
<link>https://coderlegion.com/28017/my-err-trap-stayed-silent-until-i-added-one-letter-to-set-euo-pipefail</link>
<description>&lt;p&gt;A nine-line script, &lt;code&gt;set -euo pipefail&lt;/code&gt; on line 2 and an ERR trap on line 3 whose only job is to print the failing command and its line number. A helper function then runs &lt;code&gt;mkdir /proc/export&lt;/code&gt;, a directory that cannot be created. On bash 5.3.9 it printed mkdir's own complaint and exited 1. The trap said nothing: no command, no line, the two facts it was registered to report.&lt;/p&gt;
&lt;p&gt;By default the ERR trap is not inherited by functions, command substitutions or subshells. Errexit killed the script from inside the function; the trap, registered at the top level, never saw the failure. One letter fixes it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;set -Eeuo pipefail
trap 'echo &quot;✗ failed: ${BASH_COMMAND} (line ${LINENO})&quot; &amp;gt;&amp;amp;2' ERR
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-E&lt;/code&gt; (errtrace) makes the trap follow the script into functions. Same run, same mkdir error, now followed by &lt;code&gt;✗ failed: mkdir /proc/export (line 5)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Two more silent spots from the same session. &lt;code&gt;local out=$(false)&lt;/code&gt; inside a function carried on to the next line and the script exited 0, because &lt;code&gt;local&lt;/code&gt; succeeded and its status is the one bash checks. Declare on one line, assign on the next. And in an EXIT handler, capture &lt;code&gt;$?&lt;/code&gt; on the first line: moved below an &lt;code&gt;rm -f&lt;/code&gt;, it reports rm's zero, and a script ending in &lt;code&gt;exit 3&lt;/code&gt; exits 0.&lt;/p&gt;
&lt;p&gt;Where errexit goes silent, &lt;code&gt;nounset&lt;/code&gt; defaults and the assembled skeleton with both traps are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/safe-bash-script-template&quot;&gt;strict-mode bash script template, checked against bash 5.3&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28017/my-err-trap-stayed-silent-until-i-added-one-letter-to-set-euo-pipefail</guid>
<pubDate>Tue, 22 Sep 2026 13:00:00 +0000</pubDate>
</item>
<item>
<title>Who owns that port? One ss flag answers it without sudo</title>
<link>https://coderlegion.com/27426/who-owns-that-port-one-ss-flag-answers-it-without-sudo</link>
<description>&lt;h2 id=&quot;who-owns-that-port-one-ss-flag-answers-it-without-sudo&quot;&gt;Who owns that port? One ss flag answers it without sudo&lt;/h2&gt;
&lt;p&gt;For months my dev server started on 3001 because something already held 3000, and I kept stepping around it. When I finally asked, &lt;code&gt;ss -ltn 'sport = :3000'&lt;/code&gt; showed the port listening on every interface — with an empty Process column, because &lt;code&gt;ss -p&lt;/code&gt; only names sockets owned by your own user. Without root, it looked like nobody owned it.&lt;/p&gt;
&lt;p&gt;One flag closes most of that gap:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ss -ltnpe 'sport = :3000'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-e&lt;/code&gt; prints the socket's cgroup, and on a systemd machine the end of that path is the unit that opened it. Mine ended in &lt;code&gt;docker.service&lt;/code&gt;. &lt;code&gt;docker ps&lt;/code&gt; then named it: a &lt;code&gt;docker-proxy&lt;/code&gt; publishing a container I had started in the spring and forgotten, reachable from the whole network.&lt;/p&gt;
&lt;p&gt;Two things worth carrying away. The host only ever sees Docker's proxy, never the application behind it, so a host-side port list tells you what is exposed, not what is running. And &quot;list open ports&quot; is really three questions: what is listening, who owns it, and whether it is reachable. They need three different commands.&lt;/p&gt;
&lt;p&gt;The full write-up covers all three — with and without root, without netstat, inside containers, and an audit script that diffs the listener list between runs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/open-ports-linux&quot;&gt;how to list open ports on Linux with the owning process&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27426/who-owns-that-port-one-ss-flag-answers-it-without-sudo</guid>
<pubDate>Wed, 16 Sep 2026 17:29:08 +0000</pubDate>
</item>
<item>
<title>Your Cron Job Works. That's Not the Same as Surviving Cron.</title>
<link>https://coderlegion.com/22128/your-cron-job-works-thats-not-the-same-as-surviving-cron</link>
<description>&lt;p&gt;&lt;em&gt;Level: Intermediate · Time: 7 min · Outcome: A mental model and three guards for unattended cron jobs&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;There's a gap between a script that works when you run it and a script that survives running unattended for a year. The first only has to succeed once, with you watching, on a good day. The second has to keep working through slow disks, hung sockets, recovering services, and reboots — with nobody watching, because the entire point of cron is that nobody is watching.&lt;/p&gt;
&lt;p&gt;Almost every script I've seen take down a server was fine in a manual run and fell apart the first time conditions weren't ideal and there was no human there to notice. I've hit three versions of this personally: a sync that stacked copies until the box hit load 41, a backup that hung and didn't run for nine days without a peep, and a deploy that raced a database's boot and paged me at 11pm. Three commands, three failures, one underlying truth — &quot;works on my machine&quot; and &quot;survives cron&quot; are different engineering problems.&lt;/p&gt;
&lt;p&gt;Cron jobs die quietly in exactly three ways, and there's a fourth problem underneath all of them that keeps the first three invisible.&lt;/p&gt;
&lt;h3 id=&quot;the-invisible-problem-first-cron-eats-your-output&quot;&gt;The invisible problem first: cron eats your output&lt;/h3&gt;
&lt;p&gt;When cron runs a job, anything it prints to stdout or stderr goes nowhere unless you've configured otherwise. No log, no record, no trace. So a job can fail every night for a month and the only signal is the absence of whatever it was supposed to produce — and you notice that when something downstream breaks, not when the job fails.&lt;/p&gt;
&lt;p&gt;Two things turn cron from silent to legible. &lt;code&gt;MAILTO&lt;/code&gt; at the top of the crontab mails you any output a job produces. More reliably, redirect every job's output to a log file you control:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;*/5 * * * * /usr/local/bin/safe-sync.sh &amp;gt;&amp;gt; /var/log/sync.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; is the part people forget — it folds stderr into the same stream as stdout, so your errors land in the log instead of evaporating. Everything below assumes you've done this. A locked, time-bounded, retried job that still logs nothing is a job whose failures you'll discover by accident.&lt;/p&gt;
&lt;h3 id=&quot;overlap-lock-it-to-one-instance&quot;&gt;Overlap: lock it to one instance&lt;/h3&gt;
&lt;p&gt;This is the failure that turns a transient slow patch into a self-inflicted outage. A sync scheduled every minute normally finishes in twenty seconds; one slow-disk afternoon it starts taking ninety, and cron launches a new copy every minute on top of the last, until a half-dozen copies are fighting over the same disk and the load climbs into the dozens.&lt;/p&gt;
&lt;p&gt;The guard is &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-flock-single-instance&quot;&gt;&lt;code&gt;flock&lt;/code&gt;&lt;/a&gt;: a kernel-held lock on an open file descriptor that lets exactly one copy run and makes the rest skip. Unlike a PID file, there's nothing to clean up — the kernel releases the lock automatically when the process exits, crashes, or is killed, so you never inherit a stale lock from a run that died badly. But a lock has a sharp edge: if the locked job hangs, it holds the lock forever and every future run skips. The job stops running entirely, silently. Which is the next problem.&lt;/p&gt;
&lt;h3 id=&quot;hang-bound-the-runtime&quot;&gt;Hang: bound the runtime&lt;/h3&gt;
&lt;p&gt;A hung command is worse than a failed one, because a failed command at least exits and frees its lock. A backup wedged on a held database lock, a &lt;code&gt;curl&lt;/code&gt; against a dead socket, an &lt;code&gt;ssh&lt;/code&gt; into a black hole — these never return. Under cron, &quot;never returns&quot; means the slot is jammed, the lock is held, and the job produces zero signal because it never gets far enough to log anything.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-timeout-command&quot;&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;/a&gt; is the outside bound the command can't set on itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# SIGTERM at 5 minutes; if it's ignored (process stuck in I/O), SIGKILL 20s later
timeout -k 20s 5m mysqldump --single-transaction mydb &amp;gt; /backup/mydb.sql.partial
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It exits &lt;code&gt;124&lt;/code&gt; when it had to step in and &lt;code&gt;137&lt;/code&gt; when the command ignored the polite SIGTERM and had to be force-killed — codes worth branching on, because &quot;slow&quot; and &quot;wedged&quot; are different problems. The &lt;code&gt;-k&lt;/code&gt; grace matters specifically for processes stuck in uninterruptible I/O, which can't act on SIGTERM at all. Timeout is what makes a lock safe: with both in place, a hang gets killed on a deadline, the lock always releases on time, and the failure becomes a loud &lt;code&gt;124&lt;/code&gt; instead of a silent gap.&lt;/p&gt;
&lt;h3 id=&quot;transient-failure-retry-with-backoff&quot;&gt;Transient failure: retry with backoff&lt;/h3&gt;
&lt;p&gt;Not every failure deserves to kill the run. A fresh database container that isn't accepting connections for six seconds, a 503 during a rolling restart, a 429 explicitly asking you to back off — these are transient. Treating the first one as fatal is how a normal six-second boot delay becomes a failed deploy at eleven at night.&lt;/p&gt;
&lt;p&gt;The guard is a bounded &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&quot;&gt;retry with exponential backoff&lt;/a&gt;: try the command, and on failure wait a delay that doubles each round, with jitter so parallel callers don't retry in lockstep, capped so a genuinely dead dependency fails fast instead of looping forever.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Wait for the port to be ready before doing the work that depends on it
retry 6 nc -z -w 2 db.internal 5432
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The discipline that makes retries safe rather than dangerous: retry transient failures only. A 404 fails identically on every attempt, so retrying it just delays the real error and buries it under retry noise.&lt;/p&gt;
&lt;h3 id=&quot;composing-all-three-and-knowing-which-job-needs-which&quot;&gt;Composing all three — and knowing which job needs which&lt;/h3&gt;
&lt;p&gt;Locking, bounding, and retrying are independent guards, and the real value is stacking them: a single run that can't overlap, can't hang, and rides out a blip — and tells you, by mail or log, when it finally does give up. That composition is mechanical enough to generate, which is exactly what the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-wrapper-generator&quot;&gt;Hardened Cron Wrapper Generator&lt;/a&gt; does: paste the command, toggle the guards, get a ShellCheck-clean wrapper and the crontab line.&lt;/p&gt;
&lt;p&gt;But not every job needs all of it — wrapping a one-line &lt;code&gt;date&lt;/code&gt; echo in flock and timeout is ceremony. Add a lock to anything that mutates shared state and can outrun its interval. Add a timeout to anything that touches the network or a database. Add retries to anything that depends on something which boots or recovers on its own clock. Log everything, always. The full decision table — guard by guard, when to add it and when to skip it — is in the guide.&lt;/p&gt;
&lt;p&gt;Read the whole thing, including the &quot;know when it broke&quot; alerting layer and the decision matrix: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&quot;&gt;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The three guards as standalone snippets: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-flock-single-instance&quot;&gt;flock&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-timeout-command&quot;&gt;timeout&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&quot;&gt;retry with backoff&lt;/a&gt;. To write a new hardened script from a blank slate rather than wrap an existing command, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-boilerplate-generator&quot;&gt;Bash Boilerplate Generator&lt;/a&gt; and the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-error-handling&quot;&gt;error-handling snippet&lt;/a&gt; cover the skeleton. The rest of the library is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/22128/your-cron-job-works-thats-not-the-same-as-surviving-cron</guid>
<pubDate>Wed, 08 Jul 2026 23:44:51 +0000</pubDate>
</item>
<item>
<title>Three Lines of Bash Stand Between Your Cron Job and a Silent Outage</title>
<link>https://coderlegion.com/21961/three-lines-of-bash-stand-between-your-cron-job-and-a-silent-outage</link>
<description>&lt;h2 id=&quot;three-lines-of-bash-stand-between-your-cron-job-and-a-silent-outage&quot;&gt;Three Lines of Bash Stand Between Your Cron Job and a Silent Outage&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Level: Intermediate · Time: 6 min · Outcome: A hardened, ShellCheck-clean cron wrapper generated for you&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Three separate incidents taught me the same lesson over about a year. A &lt;code&gt;*/1&lt;/code&gt; rsync stacked six copies on a slow disk and drove a box to load 41. A &lt;code&gt;mysqldump&lt;/code&gt; hung on a database lock and didn't run for nine days without a single alert. A deploy raced a database's boot and paged me at 11pm for a migration that wasn't broken. Different commands, different failures — but the same root shape every time: a script that was fine when I ran it by hand fell over the first time conditions weren't ideal and nobody was watching.&lt;/p&gt;
&lt;p&gt;The fixes are individually small. A cron job that can't take down a server needs three guards: a lock so it can't overlap itself, a timeout so it can't hang forever, and a bounded retry so a transient blip doesn't kill the run. The problem isn't knowing that. It's that composing the three correctly by hand — getting the &lt;code&gt;flock&lt;/code&gt; file descriptor right, the &lt;code&gt;timeout&lt;/code&gt; SIGKILL grace right, the retry quoting right, and not mangling a pipeline in the process — is exactly the kind of fiddly that produces a wrapper that looks right and silently isn't.&lt;/p&gt;
&lt;p&gt;So I built a generator that does the composition: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-wrapper-generator&quot;&gt;the Hardened Cron Wrapper Generator&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;what-it-actually-does&quot;&gt;What it actually does&lt;/h3&gt;
&lt;p&gt;You paste in the command your cron job runs, toggle the guards you want, set the schedule, and it emits two things: a complete wrapper script and the crontab line that calls it. The generated bash follows a strict standard — &lt;code&gt;set -euo pipefail&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;/&lt;code&gt;CROSS&lt;/code&gt; defined before use, comments that explain why each line is there — and it's ShellCheck-clean, which is the part that matters when you're pasting something into a root crontab.&lt;/p&gt;
&lt;p&gt;The toggles map exactly to the three failure modes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lock (flock)&lt;/strong&gt; stops overlap. You pick the behavior: Skip if busy (&lt;code&gt;-n&lt;/code&gt;) for frequent jobs where a missed run is harmless because the next one catches up, or Wait then give up (&lt;code&gt;-w&lt;/code&gt;) for jobs that must eventually run but can tolerate a short queue. The tool puts the lock file under &lt;code&gt;/run/lock&lt;/code&gt; — a tmpfs cleared on reboot — not &lt;code&gt;/tmp&lt;/code&gt;, where temp-cleaners can delete a lock mid-run and let a second copy through. That single path choice is a bug people ship constantly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bound the runtime (timeout)&lt;/strong&gt; stops hangs. You set the max runtime and a SIGKILL grace period. The grace exists because &lt;code&gt;timeout&lt;/code&gt; first sends a polite SIGTERM, and a process wedged in uninterruptible I/O — a dead NFS mount, a held database lock — physically can't act on SIGTERM. The grace is how long to wait before sending SIGKILL, which the kernel enforces unconditionally. The tool sets a sane default; you tune it for jobs you know touch slow storage.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retry transient failures&lt;/strong&gt; rides out the blip. You set max attempts and a base delay, and the backoff doubles each round up to a 30-second cap, with jitter so parallel callers don't retry in lockstep and re-overwhelm a recovering service.&lt;/p&gt;
&lt;h3 id=&quot;the-part-that-makes-the-output-trustworthy&quot;&gt;The part that makes the output trustworthy&lt;/h3&gt;
&lt;p&gt;Two design decisions in the tool are worth calling out because they're the ones I'd get wrong by hand at 11pm.&lt;/p&gt;
&lt;p&gt;First, the crontab line changes shape depending on what you enable. Lock and timeout are simple enough to inline directly on the crontab line — &lt;code&gt;flock&lt;/code&gt; and &lt;code&gt;timeout&lt;/code&gt; in front of the command, a one-liner. But retry and email-on-failure need a bash function and a &lt;code&gt;mailx&lt;/code&gt; call, which can't live on a crontab line. So when you turn either of those on, the output switches from an inline crontab one-liner to a full wrapper script plus a crontab line that calls it. The tool makes that decision for you instead of generating something that won't run.&lt;/p&gt;
&lt;p&gt;Second, it's explicit about pipelines. &lt;code&gt;timeout&lt;/code&gt; and the retry loop bound the single process they launch — so a raw &lt;code&gt;cmd1 | cmd2&lt;/code&gt; would only be partly guarded. The tool surfaces this: for a pipeline or multiple commands, you wrap them in &lt;code&gt;bash -c '...'&lt;/code&gt; yourself, and it tells you so, rather than silently generating a wrapper that bounds half your command.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# A pipeline must be wrapped so timeout/retry bound the whole thing, not just curl
bash -c 'curl -fsS https://api.example.com | jq .status'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also handles the thing underneath all three guards that people forget: logging. Cron throws stdout and stderr away by default, so a job can fail every night for a month and the only signal is the absence of whatever it was supposed to produce. The tool's timestamped-logging toggle redirects output to a file you control, and the email-alert toggle sends a &lt;code&gt;mailx&lt;/code&gt; failure notice after all retries are exhausted — so the job fails loudly instead of disappearing.&lt;/p&gt;
&lt;h3 id=&quot;why-generate-it-instead-of-memorizing-it&quot;&gt;Why generate it instead of memorizing it&lt;/h3&gt;
&lt;p&gt;Because the failure mode of hand-writing these is that the wrapper looks correct and isn't. The &lt;code&gt;flock&lt;/code&gt; that used &lt;code&gt;/tmp&lt;/code&gt;. The &lt;code&gt;timeout&lt;/code&gt; with no &lt;code&gt;-k&lt;/code&gt; that left the wedged process alive. The retry loop that retried a 404 forever. Each of those is a one-character or one-decision mistake, and each one only shows up the night it matters. A generator that bakes the right defaults in — and explains them inline in the output so you can read what you're shipping — removes that whole class of error.&lt;/p&gt;
&lt;p&gt;Build your wrapper here: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-wrapper-generator&quot;&gt;https://bashsnippets.xyz/tools/cron-wrapper-generator&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The three snippets it composes, if you want the full reasoning behind each guard, are &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-flock-single-instance&quot;&gt;flock&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-timeout-command&quot;&gt;timeout&lt;/a&gt;, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&quot;&gt;retry with backoff&lt;/a&gt;. Build the schedule itself in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-job-builder&quot;&gt;Cron Job Builder&lt;/a&gt;, and read the whole decision in &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&quot;&gt;Bash Scripts That Survive Cron&lt;/a&gt;. The rest of the library is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-wrapper-generator&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/21961/three-lines-of-bash-stand-between-your-cron-job-and-a-silent-outage</guid>
<pubDate>Sun, 05 Jul 2026 17:40:41 +0000</pubDate>
</item>
<item>
<title>I Spent 40 Minutes at 11pm Debugging a Deploy That Wasn't Broken</title>
<link>https://coderlegion.com/21730/i-spent-40-minutes-at-11pm-debugging-a-deploy-that-wasnt-broken</link>
<description>&lt;p&gt;&lt;em&gt;Level: Intermediate · Time: 6 min · Outcome: A deploy that waits for its dependencies instead of racing them&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I once spent forty minutes at eleven at night debugging a deploy that wasn't broken. The release script ran the database migration, the migration threw &lt;code&gt;connection refused&lt;/code&gt;, the script exited non-zero, the deploy rolled itself back, and I got paged.&lt;/p&gt;
&lt;p&gt;So I did the things you do. I read the migration. I read the logs. I checked the database — it was up, it was healthy, it accepted my connection instantly. I re-ran the deploy and it worked. I chalked it up to gremlins and went to bed, which is the part I'm not proud of, because it happened again two days later. That time I watched the timing: the script brought up a fresh database container and started the migration about six seconds before Postgres finished initializing and began accepting connections. The migration was racing the database's boot. Most of the time it won. The times it lost, I lost forty minutes.&lt;/p&gt;
&lt;p&gt;The script wasn't wrong about anything except one assumption: that a dependency is ready the instant you ask for it.&lt;/p&gt;
&lt;h3 id=&quot;in-production-dependencies-are-eventually-ready&quot;&gt;In production, dependencies are eventually ready&lt;/h3&gt;
&lt;p&gt;That's the mental model shift. Networks blip. A service you call returns a 503 for the two seconds it takes to finish a rolling restart. An API rate-limits you with a 429 it fully expects you to retry. A fresh container's database isn't accepting connections for its first few seconds. Treating the first failure as fatal turns every one of these normal, transient conditions into a paged engineer — and the script that handles them isn't smarter — it declines to give up on the first try.&lt;/p&gt;
&lt;p&gt;But retrying naively is its own trap. Retry instantly and you hammer a recovering service into staying down. Retry forever and a genuinely dead dependency hangs your script indefinitely. Retry a 404 and you wait a minute to confirm what you already knew. Good retries are bounded, backed off, and selective.&lt;/p&gt;
&lt;h3 id=&quot;a-retry-function-you-can-reuse-anywhere&quot;&gt;A retry function you can reuse anywhere&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Purpose: survive transient failures instead of dying on the first error
set -euo pipefail

CHECK=&quot;✓&quot;
CROSS=&quot;✗&quot;

# retry &amp;lt;max_attempts&amp;gt; &amp;lt;command&amp;gt; [args...]
retry() {
    local max_attempts=&quot;$1&quot;; shift
    local attempt=1
    local delay=1            # base delay — doubles each round
    local max_delay=30       # cap so the backoff never runs away

    until &quot;$@&quot;; do
        if (( attempt &amp;gt;= max_attempts )); then
            echo &quot;$CROSS '$*' failed after $attempt attempts&quot; &amp;gt;&amp;amp;2
            return 1
        fi
        # 0–2s of jitter so parallel callers don't all retry on the same beat
        local pause=$(( delay + RANDOM % 3 ))
        echo &quot;$CROSS attempt $attempt failed — retrying in ${pause}s&quot; &amp;gt;&amp;amp;2
        sleep &quot;$pause&quot;
        attempt=$(( attempt + 1 ))
        delay=$(( delay * 2 ))
        (( delay &amp;gt; max_delay )) &amp;amp;&amp;amp; delay=$max_delay
    done

    echo &quot;$CHECK '$*' succeeded on attempt $attempt&quot;
}

# The actual fix for my 11pm deploy: wait for Postgres to accept connections.
retry 6 nc -z -w 2 db.internal 5432
echo &quot;$CHECK database reachable — running migration&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The whole engine is &lt;code&gt;until &quot;$@&quot;; do ... done&lt;/code&gt;. &lt;code&gt;until&lt;/code&gt; runs the command and executes the loop body only when it fails, exiting the instant it succeeds. Passing the command as &lt;code&gt;&quot;$@&quot;&lt;/code&gt; (after &lt;code&gt;shift&lt;/code&gt;-ing past the attempt count) means the function retries anything — a &lt;code&gt;curl&lt;/code&gt;, an &lt;code&gt;ssh&lt;/code&gt;, a port check, your own script — without caring what it is.&lt;/p&gt;
&lt;p&gt;The backoff is the three lines at the bottom of the loop: sleep for the current delay plus a little jitter, then double the delay, capped at &lt;code&gt;max_delay&lt;/code&gt;. That gives you 1s, 2s, 4s, 8s, 16s, 30s, 30s…&lt;/p&gt;
&lt;h3 id=&quot;the-jitter-is-not-decoration&quot;&gt;The jitter is not decoration&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;RANDOM % 3&lt;/code&gt; looks trivial, and it's the line people delete to &quot;clean up.&quot; Don't. Without jitter, a fleet of machines that all failed at the same instant — because the same service went down — will all retry at the same instant, and the same instant after that, producing a synchronized thundering herd that knocks the recovering service straight back over on every round. A few hundred milliseconds of randomness per client spreads the retries out so the service actually gets room to recover. At one machine it does nothing; at fifty it's the difference between recovery and a retry storm.&lt;/p&gt;
&lt;h3 id=&quot;the-mistake-that-makes-retries-dangerous&quot;&gt;The mistake that makes retries dangerous&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Good: a transient failure that retrying can fix
retry 6 nc -z -w 2 db.internal 5432
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Bad: retrying a deterministic failure just delays the error 30 seconds
retry 6 curl -fsS https://api.example.com/v1/thing-that-returns-404
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A retry loop is only as smart as what you point it at. A port check belongs in a loop because the answer changes — &quot;no&quot; until the database boots, then &quot;yes.&quot; A request that returns 404 returns 404 on attempt one and attempt six; the loop just postpones the failure and buries the real status under retry noise. Retry transient failures — timeouts, connection-refused, 429, 5xx, DNS hiccups. Don't retry deterministic ones — a 404, a 401, a syntax error, a missing file. When you can, branch on the exit code or HTTP status and loop only on the codes worth looping on.&lt;/p&gt;
&lt;p&gt;For plain &lt;code&gt;curl&lt;/code&gt;, its built-in &lt;code&gt;--retry 5 --retry-delay 2&lt;/code&gt; does most of this and is simpler; reach for the function when the thing you're retrying isn't curl, or when you want one backoff policy across a database probe, an ssh call, and a download at once.&lt;/p&gt;
&lt;h3 id=&quot;back-to-11pm&quot;&gt;Back to 11pm&lt;/h3&gt;
&lt;p&gt;That deploy never paged me again once the migration waited for the port instead of assuming it. The database still took its six seconds to boot, the network still blipped occasionally — retrying didn't make the dependencies faster. It stopped a normal, transient slowness from being treated as a fatal error, which is most of what &quot;production-ready&quot; means for a script.&lt;/p&gt;
&lt;p&gt;Full function with the wait-for-port pattern and the guidance on which failures to retry: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&quot;&gt;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Retries are the third leg of an unattended job: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-flock-single-instance&quot;&gt;flock&lt;/a&gt; stops overlap, &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-timeout-command&quot;&gt;timeout&lt;/a&gt; stops hangs, retry rides out the blip. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-wrapper-generator&quot;&gt;Hardened Cron Wrapper Generator&lt;/a&gt; wires all three into one wrapper, &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&quot;&gt;Bash Scripts That Survive Cron&lt;/a&gt; is the end-to-end version, and the rest of the library is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21730/i-spent-40-minutes-at-11pm-debugging-a-deploy-that-wasnt-broken</guid>
<pubDate>Thu, 02 Jul 2026 03:27:52 +0000</pubDate>
</item>
<item>
<title># My Backup Hadn't Run in 9 Days and Nothing Told Me</title>
<link>https://coderlegion.com/21384/my-backup-hadnt-run-in-9-days-and-nothing-told-me</link>
<description>&lt;h2 id=&quot;my-backup-hadn-t-run-in-9-days-and-nothing-told-me&quot;&gt;My Backup Hadn't Run in 9 Days and Nothing Told Me&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Level: Intermediate · Time: 5 min · Outcome: A cron command that can't hang forever&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The backup ran fine every night for fourteen months, and then it didn't run for nine days, and nothing told me. No error in the log. No failed-job alert. No bounced cron mail. The nightly &lt;code&gt;mysqldump&lt;/code&gt; had hung — the database had a long-held lock from a runaway analytics query, the dump opened its transaction and sat there waiting for it, forever.&lt;/p&gt;
&lt;p&gt;Cron launched it at 2am, it never exited, and here's the cruel part: because I'd been smart enough to wrap it in a lock so two dumps couldn't run at once, every subsequent night's run saw the lock still held by the zombie from the 9th and skipped quietly. The clever lock turned a one-night hang into a nine-day outage. I found it when I went to restore a table and discovered my newest &quot;backup&quot; was a &lt;code&gt;mysqldump&lt;/code&gt; process that had been running since the previous Tuesday.&lt;/p&gt;
&lt;p&gt;That's nine days I'd have lost if anything had gone wrong with the live database. The ten minutes of feeling foolish when I traced it back was nothing next to that.&lt;/p&gt;
&lt;h3 id=&quot;a-hung-command-is-worse-than-a-failed-one&quot;&gt;A hung command is worse than a failed one&lt;/h3&gt;
&lt;p&gt;This is the lesson worth internalizing, because it's counterintuitive. A failed command exits, frees its lock, and the next run tries again — the system self-heals. A hung command exits never. It holds resources, blocks its own future runs, and produces exactly zero signal because it never gets far enough to log anything. Failures are loud. Hangs are silent, and silence is what kills you in unattended automation.&lt;/p&gt;
&lt;p&gt;You can't rely on a command to bound its own runtime, either. The whole problem is that it's wedged somewhere it can't time itself out of — blocked in the kernel waiting on a lock, or on a dead socket that will never send the FIN it's waiting for. So you bound it from the outside.&lt;/p&gt;
&lt;h3 id=&quot;bounding-the-runtime-with-timeout&quot;&gt;Bounding the runtime with timeout&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Script: bounded-dump.sh
# Purpose: Stop a hung command from running forever and jamming the cron slot
set -euo pipefail

CHECK=&quot;✓&quot;
CROSS=&quot;✗&quot;

MAX_RUNTIME=&quot;5m&quot;   # longer than the normal worst case, well under the interval
KILL_GRACE=&quot;20s&quot;   # after SIGTERM, wait this long, then SIGKILL
DEST=&quot;/backup/mydb.sql&quot;

# In an `if` so set -e doesn't abort before we read the exit code.
# Write to a .partial file so a timed-out run never leaves a corrupt &quot;backup&quot;.
if timeout -k &quot;$KILL_GRACE&quot; &quot;$MAX_RUNTIME&quot; \
        mysqldump --single-transaction mydb &amp;gt; &quot;$DEST.partial&quot;; then
    mv &quot;$DEST.partial&quot; &quot;$DEST&quot;
    echo &quot;$CHECK dump completed within $MAX_RUNTIME&quot;
else
    code=$?
    rm -f &quot;$DEST.partial&quot;
    case &quot;$code&quot; in
        124) echo &quot;$CROSS dump exceeded $MAX_RUNTIME — terminated (SIGTERM)&quot; &amp;gt;&amp;amp;2 ;;
        137) echo &quot;$CROSS dump ignored SIGTERM — force-killed (SIGKILL)&quot; &amp;gt;&amp;amp;2 ;;
        *)   echo &quot;$CROSS dump failed with exit code $code&quot; &amp;gt;&amp;amp;2 ;;
    esac
    exit &quot;$code&quot;
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;timeout -k &quot;$KILL_GRACE&quot; &quot;$MAX_RUNTIME&quot; mysqldump ...&lt;/code&gt; is the entire mechanism. At five minutes, &lt;code&gt;timeout&lt;/code&gt; sends the dump a SIGTERM. A well-behaved program treats SIGTERM as &quot;wrap up and exit.&quot; But the dump from my outage wasn't misbehaving — it was blocked in the kernel waiting on a lock, and a process in that state physically cannot act on SIGTERM. That's what &lt;code&gt;-k 20s&lt;/code&gt; is for: twenty seconds after the polite signal, &lt;code&gt;timeout&lt;/code&gt; sends SIGKILL, which the kernel enforces unconditionally. Nothing survives SIGKILL.&lt;/p&gt;
&lt;h3 id=&quot;read-the-exit-code-it-s-the-difference-between-a-log-and-a-mystery&quot;&gt;Read the exit code — it's the difference between a log and a mystery&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;124  # still running at the deadline — SIGTERM was sent
137  # 128 + 9 — ignored SIGTERM, had to be force-killed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Collapsing every outcome into &quot;backup failed&quot; throws away the one piece of information that tells you whether you have a slow database, a wedged one, or a broken dump command. A &lt;code&gt;124&lt;/code&gt; says &quot;this is taking too long — investigate the query.&quot; A &lt;code&gt;137&lt;/code&gt; says &quot;this is wedged in I/O — investigate the lock or the mount.&quot; They point at different problems. (If you ever blank on which code is which, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-exit-code-lookup&quot;&gt;Bash Exit Code Lookup&lt;/a&gt; decodes 124 and 137 directly.)&lt;/p&gt;
&lt;h3 id=&quot;the-partial-trick-matters-more-than-it-looks&quot;&gt;The .partial trick matters more than it looks&lt;/h3&gt;
&lt;p&gt;If you redirect straight to the real backup file and the command times out mid-write, you've just replaced last night's good backup with a half-written, unrestorable file — and you won't know until the night you need it. Writing to a &lt;code&gt;.partial&lt;/code&gt; path and &lt;code&gt;mv&lt;/code&gt;-ing into place only on a clean exit means a failed or timed-out run leaves the previous good backup untouched. A &lt;code&gt;mv&lt;/code&gt; on the same filesystem is atomic; the redirect is not.&lt;/p&gt;
&lt;p&gt;For commands that talk to the network, layer the tool's own timeout underneath — &lt;code&gt;curl --max-time&lt;/code&gt;, &lt;code&gt;ssh -o ConnectTimeout&lt;/code&gt;, a &lt;code&gt;net_read_timeout&lt;/code&gt; on the dump. Those fire first and fail cleanly. &lt;code&gt;timeout&lt;/code&gt; is the outer hard stop for the night the inner one doesn't.&lt;/p&gt;
&lt;h3 id=&quot;back-to-the-nine-days&quot;&gt;Back to the nine days&lt;/h3&gt;
&lt;p&gt;A timeout is what makes a lock safe. Locking a job to a single instance stops overlap, but a hang inside the locked job holds that lock forever — which is precisely how my nine-day gap happened. Bound the runtime and the lock always gets released, on a deadline, every time, and the failure becomes a loud &lt;code&gt;124&lt;/code&gt; in the log instead of a silent gap you discover during a restore.&lt;/p&gt;
&lt;p&gt;Full script with the exit-code branching and the FAQ on timing out a whole pipeline: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-timeout-command&quot;&gt;https://bashsnippets.xyz/snippets/bash-timeout-command&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The lock that made my hang invisible is &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-flock-single-instance&quot;&gt;flock&lt;/a&gt;, and the third guard is &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&quot;&gt;retry with backoff&lt;/a&gt;. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-wrapper-generator&quot;&gt;Hardened Cron Wrapper Generator&lt;/a&gt; composes all three, &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&quot;&gt;Bash Scripts That Survive Cron&lt;/a&gt; walks the whole decision, and the rest of the library is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-timeout-command&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21384/my-backup-hadnt-run-in-9-days-and-nothing-told-me</guid>
<pubDate>Fri, 26 Jun 2026 03:41:25 +0000</pubDate>
</item>
<item>
<title># A Cron Job Took Our Server to Load 41 by Attacking Itself</title>
<link>https://coderlegion.com/21166/a-cron-job-took-our-server-to-load-41-by-attacking-itself</link>
<description>&lt;p&gt;A &lt;code&gt;*/1&lt;/code&gt; rsync took our staging box to a load average of 41 one afternoon, and it took me longer than I want to admit to work out why. The sync normally finished in about twenty seconds. That day the backup target's NFS mount went sluggish, the sync started taking ninety seconds, and cron — which does not know or care whether the last run is still going — launched a fresh copy every single minute on top of it.&lt;/p&gt;
&lt;p&gt;Inside ten minutes there were a half-dozen rsyncs all reading the same tree off the same slow disk, each one making the disk slower, each new minute adding another. The box wasn't under attack. It was attacking itself, one polite copy at a time. The thing that stung was that nothing was broken — every individual rsync was correct, the disk eventually recovered on its own, and the only reason it became an outage is that cron has no concept of &quot;the last one is still running.&quot;&lt;/p&gt;
&lt;p&gt;That's the trap with scheduled jobs: a command that's perfectly fine when you run it by hand can take down a server the first time it runs longer than its interval with nobody watching.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-everyone-reaches-for-first-is-the-wrong-one&quot;&gt;The fix everyone reaches for first is the wrong one&lt;/h3&gt;
&lt;p&gt;The instinct is a PID file: write &lt;code&gt;$$&lt;/code&gt; to &lt;code&gt;/var/run/job.pid&lt;/code&gt; on start, check whether that file exists on the next run, bail if it does. It almost works. Then one run gets &lt;code&gt;kill -9&lt;/code&gt;'d, or the box reboots mid-job, and the PID file is left behind pointing at a process that died on Tuesday. Now every future run sees a &quot;lock&quot; owned by a PID that no longer exists, and the job never runs again — the opposite failure, just as silent.&lt;/p&gt;
&lt;p&gt;There's also a race between the check and the write, and the times you most need the lock to be clean are exactly the times cleanup didn't happen, because the process died before it could clean up.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;flock&lt;/code&gt; has none of that. The lock isn't a file you create and delete — it's a lock the kernel holds on an open file descriptor, and the kernel releases it automatically the instant that descriptor closes. The process exiting closes it. So does crashing. So does &lt;code&gt;kill -9&lt;/code&gt;. There is no state to leave behind, which is the entire reason it survives the failure modes a PID file can't.&lt;/p&gt;
&lt;h3 id=&quot;the-single-instance-pattern&quot;&gt;The single-instance pattern&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Script: backup-with-lock.sh
# Purpose: Stop a cron job from overlapping itself when one run runs long
set -euo pipefail

CHECK=&quot;✓&quot;
CROSS=&quot;✗&quot;

# /run/lock is tmpfs, cleared cleanly on reboot. Never /tmp — temp-cleaners
# delete files there, and a deleted lock mid-run lets a second copy run.
LOCK_FILE=&quot;/run/lock/$(basename &quot;$0&quot;).lock&quot;

# The &amp;gt; opens (and creates) the lock file on fd 200 and holds it open for the
# whole script. The lock lives on this descriptor, not on the file existing.
exec 200&amp;gt;&quot;$LOCK_FILE&quot;

# -n = non-blocking: if a previous run still holds the lock, give up now
# instead of queueing another copy behind it.
if ! flock -n 200; then
    echo &quot;$CROSS $(date '+%F %T') previous run still active — skipping&quot; &amp;gt;&amp;amp;2
    exit 0
fi

echo &quot;$CHECK $(date '+%F %T') lock acquired — starting&quot;
rsync -a --delete /data/ /mnt/backup/data/
echo &quot;$CHECK $(date '+%F %T') finished — kernel releases the lock on exit&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two lines doing the work are &lt;code&gt;exec 200&amp;gt;&quot;$LOCK_FILE&quot;&lt;/code&gt; and &lt;code&gt;flock -n 200&lt;/code&gt;. The first opens the lock file on a descriptor that stays open for the life of the process. The second tries to grab the lock without waiting; if a sibling process already holds it, &lt;code&gt;flock&lt;/code&gt; returns non-zero, we log it and exit &lt;code&gt;0&lt;/code&gt; — a skipped run is normal, not an error, so we don't want it lighting up cron's mail.&lt;/p&gt;
&lt;p&gt;Notice there is no cleanup. No &lt;code&gt;trap&lt;/code&gt; to remove a PID file, no &lt;code&gt;rm&lt;/code&gt; at the end. When this script exits for any reason, fd 200 closes and the lock is gone. That &quot;for any reason&quot; is the whole point.&lt;/p&gt;
&lt;h3 id=&quot;you-can-lock-a-job-without-editing-it-at-all&quot;&gt;You can lock a job without editing it at all&lt;/h3&gt;
&lt;p&gt;If the misbehaving job is already deployed and you don't want to touch it, wrap it from the crontab line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Skip the run if the last one is still going
*/1 * * * * /usr/bin/flock -n /run/lock/sync.lock /usr/local/bin/sync.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;flock&lt;/code&gt; runs &lt;code&gt;sync.sh&lt;/code&gt; only if it can grab the lock; if last minute's run is still holding it, this minute's run exits immediately and does nothing. It's the fastest retrofit for a job that's already on fire — no redeploy.&lt;/p&gt;
&lt;p&gt;One thing worth burning into memory: &lt;code&gt;-n&lt;/code&gt; skips, &lt;code&gt;-w 30&lt;/code&gt; waits up to thirty seconds then gives up, and a bare &lt;code&gt;flock&lt;/code&gt; with neither blocks forever. On a fast cron schedule that bare form turns your &quot;skipped&quot; runs into a pile of stuck processes — the exact thing you were trying to prevent.&lt;/p&gt;
&lt;h3 id=&quot;the-part-that-actually-mattered&quot;&gt;The part that actually mattered&lt;/h3&gt;
&lt;p&gt;The load-41 afternoon ended the moment I wrapped that rsync in &lt;code&gt;flock -n&lt;/code&gt;. The slow NFS mount was still slow, but now exactly one sync ran at a time and the extras skipped harmlessly until the disk recovered. Locking didn't fix the slow disk — it stopped a transient slow disk from becoming a self-inflicted outage. That's the difference between a script that works when you run it and one that survives unattended.&lt;/p&gt;
&lt;p&gt;A lock alone isn't the whole story, though. If the locked job itself hangs, it holds the lock forever and every future run skips — so the job silently stops running and you find out days later. That's why locking pairs with bounding runtime with &lt;code&gt;timeout&lt;/code&gt; and retrying transient failures.&lt;/p&gt;
&lt;p&gt;Full script, the &lt;code&gt;-n&lt;/code&gt; vs &lt;code&gt;-w&lt;/code&gt; decision, and the FAQ on where the lock file should live: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-flock-single-instance&quot;&gt;https://bashsnippets.xyz/snippets/bash-flock-single-instance&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you're hardening a cron job, the next two guards are &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-timeout-command&quot;&gt;timeout&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-retry-with-backoff&quot;&gt;retry with backoff&lt;/a&gt;; the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-wrapper-generator&quot;&gt;Hardened Cron Wrapper Generator&lt;/a&gt; stitches all three into one wrapper, and the full reasoning is in &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron&quot;&gt;Bash Scripts That Survive Cron&lt;/a&gt;. The rest of the library is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-flock-single-instance&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/21166/a-cron-job-took-our-server-to-load-41-by-attacking-itself</guid>
<pubDate>Tue, 23 Jun 2026 00:16:48 +0000</pubDate>
</item>
<item>
<title>A Function Without local Overwrote My Variable and rm -rf Deleted the Wrong Directory</title>
<link>https://coderlegion.com/21095/a-function-without-local-overwrote-my-variable-and-rm-rf-deleted-the-wrong-directory</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Bash variables are global by default, so a helper function clobbered the caller's path variable and rm -rf cleaned up the wrong directory. local, return-as-status, and getopts — all in one incident.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; #bash  #linux  #scripting  #devops&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The deploy script had been running in production for four months without a problem. It built releases into a temp directory, ran some validation, and then cleaned up by removing whatever &lt;code&gt;$target&lt;/code&gt; pointed at. &lt;code&gt;$target&lt;/code&gt; was set near the top of the script to the current release directory — the one the running application was serving from. A helper function called &lt;code&gt;prepare()&lt;/code&gt; also used a variable named &lt;code&gt;target&lt;/code&gt;, because the person who wrote it (me, four months earlier) did not think about scope.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;prepare()&lt;/code&gt; built the new release into a temp directory. On a good run, it set &lt;code&gt;target&lt;/code&gt; to the temp path, did its work, and returned. The main script then did its cleanup at the end and removed &lt;code&gt;$target&lt;/code&gt; — which, after calling &lt;code&gt;prepare()&lt;/code&gt;, was the temp directory. That worked correctly for four months.&lt;/p&gt;
&lt;p&gt;Then a deploy failed partway through &lt;code&gt;prepare()&lt;/code&gt;. The temp directory was half-built. &lt;code&gt;target&lt;/code&gt; was now pointing at the half-built temp path. The main script caught the failure, started its cleanup, and ran &lt;code&gt;rm -rf &quot;$target&quot;&lt;/code&gt;. It removed the half-built temp directory, which was correct. But then it kept going — there was a second cleanup step that also used &lt;code&gt;$target&lt;/code&gt; and expected it to still be the release directory. By the time the script finished, it had removed the running application's release directory. The application restarted and found nothing to serve.&lt;/p&gt;
&lt;p&gt;The users noticed before I did. The restart loop was filling logs, the application was returning 502, and I was sitting in the deploy output trying to figure out what had gone wrong in a failure path I had tested against a stub.&lt;/p&gt;
&lt;h3 id=&quot;variables-in-bash-are-global-by-default&quot;&gt;Variables in bash are global by default&lt;/h3&gt;
&lt;p&gt;This is the single most surprising thing about bash functions if you have written code in almost any other language. In Python, a variable assigned inside a function is local to that function unless you explicitly declare it &lt;code&gt;global&lt;/code&gt;. In bash, it is the opposite. A variable assigned inside a function is visible — and writable — everywhere in the current shell unless you declare it &lt;code&gt;local&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;target=&quot;/srv/release/current&quot;

prepare() {
  target=$(mktemp -d)    # No local — this overwrites the global $target
  echo &quot;building in $target&quot;
}

prepare
echo &quot;target is now: $target&quot;   # Prints the temp dir, not /srv/release/current
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The function does exactly what it looks like — it sets &lt;code&gt;target&lt;/code&gt;. The problem is that it sets &lt;code&gt;target&lt;/code&gt; everywhere, not just inside itself. The caller's &lt;code&gt;target&lt;/code&gt; is gone.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;local&lt;/code&gt; confines the assignment to the function scope:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;prepare() {
  local target              # confined to this function
  target=$(mktemp -d)
  echo &quot;$target&quot;            # hand the value out via stdout
}

build_dir=$(prepare)        # capture what the function echoed
echo &quot;built in: $build_dir&quot;
echo &quot;release dir still: $target&quot;   # unchanged
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;local target&lt;/code&gt; means: this variable exists only inside this function. When the function returns, the variable and its value vanish. The caller's &lt;code&gt;target&lt;/code&gt; is never touched. The habit I now enforce on every bash function I write: &lt;code&gt;local&lt;/code&gt; for every variable the function introduces, not just the ones I think might conflict. The conflict I do not predict is the one that deletes the wrong directory.&lt;/p&gt;
&lt;h3 id=&quot;return-is-a-status-not-a-value&quot;&gt;return is a status, not a value&lt;/h3&gt;
&lt;p&gt;After the incident, I audited every function in the deploy script. I found a second bug:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;count_pending() {
  local n
  n=$(find &quot;$QUEUE_DIR&quot; -type f | wc -l)
  return &quot;$n&quot;      # WRONG if n &amp;gt; 255
}

count_pending
if [[ $? -gt 0 ]]; then
  echo &quot;queue has items&quot;
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;return&lt;/code&gt; sets an exit status. Exit statuses are a single byte: &lt;code&gt;0&lt;/code&gt; to &lt;code&gt;255&lt;/code&gt;. &lt;code&gt;return 300&lt;/code&gt; wraps to &lt;code&gt;44&lt;/code&gt;. For months the queue count had been above 255 on busy days, and the &lt;code&gt;$?&lt;/code&gt; check was comparing against a wrapped value. The logic had been wrong for months and had accidentally worked because the wrapped values still triggered the &lt;code&gt;gt 0&lt;/code&gt; condition. But any script making real decisions based on the actual count — how many workers to spin up, whether to page someone — would have been working with garbage.&lt;/p&gt;
&lt;p&gt;The correct pattern is to echo the value and capture it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;count_pending() {
  local n
  n=$(find &quot;$QUEUE_DIR&quot; -type f | wc -l)
  echo &quot;$n&quot;         # data goes to stdout
  return 0          # status: success
}

pending=$(count_pending)
echo &quot;queue depth: $pending&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;return&lt;/code&gt; answers &quot;did this succeed.&quot; &lt;code&gt;echo&lt;/code&gt; plus command substitution answers &quot;what is the value.&quot; These are two different questions and bash gives you two different mechanisms for a reason. Mixing them is how a function that counts 300 items makes the caller think it counted 44.&lt;/p&gt;
&lt;h3 id=&quot;arguments-and-the-quoting-rule&quot;&gt;Arguments and the $@ quoting rule&lt;/h3&gt;
&lt;p&gt;Inside a function, arguments arrive as positional parameters: &lt;code&gt;$1&lt;/code&gt;, &lt;code&gt;$2&lt;/code&gt;, all of them as &lt;code&gt;&quot;$@&quot;&lt;/code&gt;, the count as &lt;code&gt;$#&lt;/code&gt;. Quoting &lt;code&gt;&quot;$@&quot;&lt;/code&gt; is what keeps multi-word arguments intact:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;process_hosts() {
  echo &quot;processing $# hosts&quot;
  for host in &quot;$@&quot;; do
    echo &quot;  checking: $host&quot;
  done
}

process_hosts &quot;web-01&quot; &quot;db primary&quot; &quot;cache-02&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without the quotes around &lt;code&gt;&quot;$@&quot;&lt;/code&gt;, &lt;code&gt;db primary&lt;/code&gt; splits into two loop iterations and you are back to the word-splitting problem from a different angle. The quoted &lt;code&gt;&quot;$@&quot;&lt;/code&gt; is the way bash passes an array of arguments through a function call with each element preserved.&lt;/p&gt;
&lt;p&gt;This matters most when you are writing wrapper functions — functions that receive arguments and pass them to another command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;run_with_retry() {
  local retries=&quot;${1:?}&quot;
  shift
  local attempt=0
  while ((attempt &amp;lt; retries)); do
    &quot;$@&quot; &amp;amp;&amp;amp; return 0
    ((attempt++))
    echo &quot;retry $attempt/$retries&quot;
    sleep 2
  done
  return 1
}

run_with_retry 3 rsync -av &quot;source dir/&quot; remote:/dest/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&quot;$@&quot;&lt;/code&gt; after the &lt;code&gt;shift&lt;/code&gt; is everything after the retry count — the command and all its arguments, each preserved as a separate item even if they contain spaces. Without the quotes, &lt;code&gt;&quot;source dir/&quot;&lt;/code&gt; splits and &lt;code&gt;rsync&lt;/code&gt; receives the wrong arguments.&lt;/p&gt;
&lt;h3 id=&quot;getopts-for-anything-with-flags&quot;&gt;getopts for anything with flags&lt;/h3&gt;
&lt;p&gt;For one or two fixed positional arguments, reading &lt;code&gt;$1&lt;/code&gt; and &lt;code&gt;$2&lt;/code&gt; directly is fine. The moment a function or script takes optional flags in any order, do not parse them by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;verbose=0
output_dir=&quot;&quot;

while getopts &quot;vd:&quot; opt; do
  case &quot;$opt&quot; in
    v) verbose=1 ;;
    d) output_dir=&quot;$OPTARG&quot; ;;
    *) echo &quot;usage: $0 [-v] [-d dir]&quot;; exit 1 ;;
  esac
done
shift $((OPTIND - 1))   # move past the flags to positional args
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The colon after &lt;code&gt;d&lt;/code&gt; marks it as requiring an argument, which arrives in &lt;code&gt;$OPTARG&lt;/code&gt;. &lt;code&gt;getopts&lt;/code&gt; handles flag bundling (&lt;code&gt;-vd dir&lt;/code&gt;), missing arguments (&lt;code&gt;-d&lt;/code&gt; with no path generates an error automatically), and unknown flags. Hand-rolled &lt;code&gt;$1&lt;/code&gt; parsing gets all of these wrong in subtle ways — it accepts &lt;code&gt;-d&lt;/code&gt; at the end without a value, it does not handle &lt;code&gt;-vd dir&lt;/code&gt;, and it requires the flags in a specific order.&lt;/p&gt;
&lt;p&gt;The deploy script that caused the incident had hand-rolled argument parsing. Among other things, it silently accepted a &lt;code&gt;--target&lt;/code&gt; flag with no value and proceeded with an empty string, which caused a different class of problem I had also not fully traced before the bigger incident made the whole thing visible.&lt;/p&gt;
&lt;h3 id=&quot;what-the-deploy-script-looks-like-now&quot;&gt;What the deploy script looks like now&lt;/h3&gt;
&lt;p&gt;Every function declares &lt;code&gt;local&lt;/code&gt; for every variable. Values that need to cross function boundaries go through &lt;code&gt;echo&lt;/code&gt; and command substitution. Exit statuses communicate success or failure. &lt;code&gt;getopts&lt;/code&gt; handles the flags. There is a &lt;code&gt;trap&lt;/code&gt; on &lt;code&gt;EXIT&lt;/code&gt; that cleans up the temp directory using a local variable that only the cleanup function can see:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cleanup() {
  local tmp_dir=&quot;${TEMP_BUILD_DIR:-}&quot;   # local ref to the temp dir
  [[ -n &quot;$tmp_dir&quot; &amp;amp;&amp;amp; -d &quot;$tmp_dir&quot; ]] &amp;amp;&amp;amp; rm -rf &quot;$tmp_dir&quot;
}
trap cleanup EXIT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;$target&lt;/code&gt; variable in the main script is set once at the top and never touched by any function. Functions that need a temp directory create one, store it in a local variable, use it, and the cleanup trap handles removal. The variable naming conflict that caused the incident cannot happen because the pattern prevents it structurally.&lt;/p&gt;
&lt;p&gt;The application has been running cleanly since then. The deploy script has hit the failure path twice since the fix — different failures, unrelated causes — and both times the cleanup ran correctly and left the running release untouched.&lt;/p&gt;
&lt;p&gt;Full version with the local-scope fix, the echo-for-values pattern, the return-as-status trap, and a getopts template: &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-functions-arguments&quot;&gt;https://bashsnippets.xyz/snippets/bash-functions-arguments&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A function that fails should fail loudly — wrap the script in &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-error-handling&quot;&gt;set -euo pipefail&lt;/a&gt; — and the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-boilerplate-generator&quot;&gt;bash boilerplate generator&lt;/a&gt; can scaffold all of this with the right traps and argument parsing wired in from the start. The rest is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-functions-arguments&quot;&gt;bashsnippets.xyz/snippets/bash-functions-arguments&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21095/a-function-without-local-overwrote-my-variable-and-rm-rf-deleted-the-wrong-directory</guid>
<pubDate>Mon, 22 Jun 2026 02:45:23 +0000</pubDate>
</item>
<item>
<title>The Alert Never Fired Because the Loop Skipped the Last Line of the File</title>
<link>https://coderlegion.com/20988/the-alert-never-fired-because-the-loop-skipped-the-last-line-of-the-file</link>
<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;h3 id=&quot;why-read-drops-the-last-line&quot;&gt;Why read drops the last line&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash

# Script: check-hosts.sh

# Purpose: ping every host in a file, including a newline-less final line

# Usage: ./check-hosts.sh hosts.txt

set -euo pipefail

CHECK=&quot;✓&quot;
CROSS=&quot;✗&quot;
HOST_FILE=&quot;${1:?Usage: check-hosts.sh &amp;lt;host-file&amp;gt;}&quot;

while IFS= read -r host || [[ -n &quot;$host&quot; ]]; do
  [[ -z &quot;$host&quot; || &quot;$host&quot; == \#* ]] &amp;amp;&amp;amp; continue
  if ping -c1 -W2 &quot;$host&quot; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then
    echo &quot;$CHECK up:   $host&quot;
  else
    echo &quot;$CROSS down: $host&quot;
  fi
done &amp;lt; &quot;$HOST_FILE&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;|| [[ -n &quot;$host&quot; ]]&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;h3 id=&quot;what-ifs-and--r-actually-do&quot;&gt;What IFS= and -r actually do&lt;/h3&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;h3 id=&quot;the-subshell-trap-that-kills-your-counters&quot;&gt;The subshell trap that kills your counters&lt;/h3&gt;
&lt;p&gt;This is the one that is most likely to make you question your sanity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;
# This looks correct. It is not.

fails=0
cat hosts.txt | while IFS= read -r host; do
  ping -c1 -W2 &quot;$host&quot; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || ((fails++))
done
echo &quot;Total failures: $fails&quot;   # Always prints 0
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;fails=0
while IFS= read -r host || [[ -n &quot;$host&quot; ]]; do
  ping -c1 -W2 &quot;$host&quot; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || ((fails++))
done &amp;lt; hosts.txt
echo &quot;Total failures: $fails&quot;   # Now correct
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;why-for-loop-is-wrong-for-this&quot;&gt;Why for loop is wrong for this&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;
# Never do this — iterates words, not lines

for line in $(cat hosts.txt); do
  ping -c1 &quot;$line&quot;
done
&lt;/code&gt;&lt;/pre&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 &quot;$line&quot; ]]; 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;h3 id=&quot;the-monitoring-system-after-the-fix&quot;&gt;The monitoring system, after the fix&lt;/h3&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 &quot;$host&quot; ]]&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 &quot;expected 12 hosts, checked 11.&quot;&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 &quot;checked 11/12 hosts&quot; 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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-read-file-line-by-line&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-for-loop-examples&quot;&gt;for loop&lt;/a&gt; instead, and wrap anything that acts on what it reads in &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-error-handling&quot;&gt;set -euo pipefail&lt;/a&gt;. More at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-read-file-line-by-line&quot;&gt;bashsnippets.xyz/snippets/bash-read-file-line-by-line&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20988/the-alert-never-fired-because-the-loop-skipped-the-last-line-of-the-file</guid>
<pubDate>Fri, 19 Jun 2026 17:58:59 +0000</pubDate>
</item>
<item>
<title>A For Loop Skipped Every File With a Space and Called the Backup a Success</title>
<link>https://coderlegion.com/20946/a-for-loop-skipped-every-file-with-a-space-and-called-the-backup-a-success</link>
<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 &quot;no such file or directory&quot; 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;h3 id=&quot;why-ls-word-splits&quot;&gt;Why $(ls) word-splits&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Script: backup-exports.sh
# Purpose: copy export files without losing ones with spaces in their names
# Usage: ./backup-exports.sh
set -euo pipefail

CHECK=&quot;✓&quot;
CROSS=&quot;✗&quot;
SRC_DIR=&quot;/data/exports&quot;
DEST_DIR=&quot;/backup/exports&quot;
shopt -s nullglob   # empty glob expands to nothing, not the literal pattern

for f in &quot;$SRC_DIR&quot;/*.xlsx; do
  if cp -- &quot;$f&quot; &quot;$DEST_DIR/&quot;; then
    echo &quot;$CHECK backed up: $(basename &quot;$f&quot;)&quot;
  else
    echo &quot;$CROSS failed: $(basename &quot;$f&quot;)&quot;
  fi
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;for f in &quot;$SRC_DIR&quot;/*.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;&quot;$f&quot;&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;h3 id=&quot;the-second-half-quoting-on-use&quot;&gt;The second half: quoting on use&lt;/h3&gt;
&lt;p&gt;The glob fixes the loop header. Quoting fixes the point of use. These are separate.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for f in &quot;$SRC_DIR&quot;/*.xlsx; do
  cp $f &quot;$DEST_DIR/&quot;    # WRONG — $f re-splits here
  cp &quot;$f&quot; &quot;$DEST_DIR/&quot;  # RIGHT — the quotes prevent the split
done
&lt;/code&gt;&lt;/pre&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;&quot;$f&quot;&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;h3 id=&quot;ranges-and-counters-where-the-brace-trap-hides&quot;&gt;Ranges and counters: where the brace trap hides&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;n=5

# This does NOT produce a range — it produces the literal string {1..5}
for i in {1..$n}; do echo &quot;attempt $i&quot;; done

# This works — C-style, evaluates variables at runtime
for ((i = 1; i &amp;lt;= n; i++)); do echo &quot;attempt $i of $n&quot;; done
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;arrays-the-original-bug-wearing-a-different-hat&quot;&gt;Arrays: the original bug wearing a different hat&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;servers=(&quot;web-01&quot; &quot;db primary&quot; &quot;cache-02&quot;)

# WRONG — word-splits &quot;db primary&quot; into two iterations
for s in ${servers[@]}; do
  ping -c1 &quot;$s&quot;
done

# RIGHT — quotes keep each element intact
for s in &quot;${servers[@]}&quot;; do
  ping -c1 &quot;$s&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&quot;${servers[@]}&quot;&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;h3 id=&quot;the-nullglob-case&quot;&gt;The nullglob case&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;shopt -s nullglob
for f in /data/exports/*.xlsx; do
  echo &quot;$f&quot;
done
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;what-happened-to-the-q3-export&quot;&gt;What happened to the Q3 export&lt;/h3&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-for-loop-examples&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-read-file-line-by-line&quot;&gt;while IFS= read -r&lt;/a&gt; — and wrap any loop that touches real files in &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-error-handling&quot;&gt;set -euo pipefail&lt;/a&gt;. The rest is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20946/a-for-loop-skipped-every-file-with-a-space-and-called-the-backup-a-success</guid>
<pubDate>Thu, 18 Jun 2026 19:28:03 +0000</pubDate>
</item>
<item>
<title>find . -delete Ran Before the Filter and Emptied the Whole Tree</title>
<link>https://coderlegion.com/20867/find-delete-ran-before-the-filter-and-emptied-the-whole-tree</link>
<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 &quot;*.cache&quot;&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 &quot;*.cache&quot;&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;h3 id=&quot;why-the-order-is-the-program&quot;&gt;Why the order is the program&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find /data -name &quot;*.cache&quot; -delete
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;it evaluates &lt;code&gt;-name &quot;*.cache&quot;&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find /data -delete -name &quot;*.cache&quot;
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;the-quoting-trap-right-behind-the-ordering-one&quot;&gt;The quoting trap, right behind the ordering one&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# WRONG — the shell expands *.cache before find ever sees it
find /data -name *.cache

# RIGHT — quote the pattern so find does the matching
find /data -name &quot;*.cache&quot;
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;the-age-sign-that-everyone-reverses-at-least-once&quot;&gt;The age sign that everyone reverses at least once&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find /var/log -name &quot;*.log&quot; -mtime +30   # older than 30 days
find /var/log -name &quot;*.log&quot; -mtime -1    # modified within the last day
find /var/log -name &quot;*.log&quot; -mtime 30    # exactly day 30 (almost never what you want)
&lt;/code&gt;&lt;/pre&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 &quot;older than&quot; a threshold and the intuitive symbol for &quot;bigger number&quot; is &lt;code&gt;+&lt;/code&gt;, but a lot of people read &lt;code&gt;+30&lt;/code&gt; as &quot;in the last thirty days&quot; 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 &quot;older than&quot; or &quot;within the last&quot; 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;h3 id=&quot;the--exec-batching-difference-nobody-explains&quot;&gt;The -exec batching difference nobody explains&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Runs the command once per file — slower, one PID per file
find /data -name &quot;*.cache&quot; -exec rm {} \;

# Batches files into one invocation — faster, one rm for many files
find /data -name &quot;*.cache&quot; -exec rm {} +
&lt;/code&gt;&lt;/pre&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;h3 id=&quot;the-preview-step-i-skipped-the-day-i-broke-things&quot;&gt;The preview step I skipped the day I broke things&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;find /data -name &quot;*.cache&quot; -print
&lt;/code&gt;&lt;/pre&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/find-command-builder&quot;&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;h3 id=&quot;what-the-builder-actually-does&quot;&gt;What the builder actually does&lt;/h3&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 &quot;modified more than 30 days ago.&quot; &lt;code&gt;-name &quot;*.cache&quot;&lt;/code&gt; reads as &quot;name matches the glob &lt;code&gt;*.cache&lt;/code&gt;, quoted so find handles the wildcard.&quot; 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 &quot;I need to find and delete files matching these conditions&quot; 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;h3 id=&quot;what-i-upgraded-while-i-was-at-it&quot;&gt;What I upgraded while I was at it&lt;/h3&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/find-command-builder&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-text-processing&quot;&gt;Bash Text Processing: find, grep, sed, and awk&lt;/a&gt;. The rest of the free tools are at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools&quot;&gt;https://bashsnippets.xyz/tools&lt;/a&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/20867/find-delete-ran-before-the-filter-and-emptied-the-whole-tree</guid>
<pubDate>Wed, 17 Jun 2026 23:57:57 +0000</pubDate>
</item>
<item>
<title>A for Loop Skipped 23 Files and Called It a Successful Backup</title>
<link>https://coderlegion.com/20592/a-for-loop-skipped-23-files-and-called-it-a-successful-backup</link>
<description>&lt;p&gt;The backup ran every night at 2am and emailed me a green &quot;847 files archived&quot; 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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for file in $(ls &quot;$DIR&quot;); do
  echo &quot;Processing $file&quot;
done
&lt;/code&gt;&lt;/pre&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 &quot;succeeds&quot; 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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for file in &quot;$DIR&quot;/*; do
  [[ -e &quot;$file&quot; ]] || continue   # the glob yields a literal '*' on an empty dir
  echo &quot;Processing $file&quot;
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&quot;$DIR&quot;/*&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 &quot;$file&quot; ]] || 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;&quot;$file&quot;&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;servers=(&quot;web-01&quot; &quot;db-prod 02&quot; &quot;cache-03&quot;)

for host in &quot;${servers[@]}&quot;; do
  echo &quot;Connecting to $host&quot;   # three clean iterations, the space survives
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&quot;${servers[@]}&quot;&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;&quot;${servers[*]}&quot;&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;for ((i = 1; i &amp;lt;= MAX_ROTATED; i++)); do
  log=&quot;$LOG_DIR/app.log.$i&quot;
  [[ -f &quot;$log&quot; ]] || continue
  echo &quot;Scanning $log&quot;
done
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;while IFS= read -r line; do
  echo &quot;Line: $line&quot;
done &amp;lt; &quot;$INPUT_FILE&quot;
&lt;/code&gt;&lt;/pre&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-for-loop-examples&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-error-handling&quot;&gt;Bash Error Handling&lt;/a&gt; snippet pairs with this one, and the rest of the library is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20592/a-for-loop-skipped-23-files-and-called-it-a-successful-backup</guid>
<pubDate>Sun, 14 Jun 2026 17:23:46 +0000</pubDate>
</item>
<item>
<title>The Pipeline Was Green for Three Weeks. It Had Been Shipping a Build That Never Compiled.</title>
<link>https://coderlegion.com/20433/the-pipeline-was-green-for-three-weeks-it-had-been-shipping-a-build-that-never-compiled</link>
<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 &quot;the logs show an error&quot; and &quot;the pipeline reports failure&quot; 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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm run build | tee build.log
build_rc=&quot;${PIPESTATUS[0]}&quot;   # npm's code, not tee's
[[ &quot;$build_rc&quot; -eq 0 ]] || { echo &quot;build failed: $build_rc&quot; &amp;gt;&amp;amp;2; exit &quot;$build_rc&quot;; }
&lt;/code&gt;&lt;/pre&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 &quot;$@&quot;&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 &quot;$@&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-trap-builder&quot;&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 &quot;what's running right now&quot; 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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/guides/bash-scripting-for-ci-cd-pipelines&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20433/the-pipeline-was-green-for-three-weeks-it-had-been-shipping-a-build-that-never-compiled</guid>
<pubDate>Fri, 12 Jun 2026 16:25:56 +0000</pubDate>
</item>
<item>
<title>My Script Crashed and Left a Lock File Behind. Every Run After That Refused to Start.</title>
<link>https://coderlegion.com/20323/my-script-crashed-and-left-a-lock-file-behind-every-run-after-that-refused-to-start</link>
<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 &quot;already running,&quot; 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. &quot;Copy trap block only&quot; when you've already got a script and just want to drop the traps in. &quot;Copy complete script header&quot; 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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-trap-builder&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-error-handling&quot;&gt;Bash Error Handling&lt;/a&gt; snippet is the companion read, and the rest of the tools are at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools&quot;&gt;https://bashsnippets.xyz/tools&lt;/a&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/20323/my-script-crashed-and-left-a-lock-file-behind-every-run-after-that-refused-to-start</guid>
<pubDate>Thu, 11 Jun 2026 16:55:08 +0000</pubDate>
</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>
<link>https://coderlegion.com/20082/i-packaged-the-scripts-i-copy-to-every-new-server-into-a-9-toolkit-heres-whats-in-it-and-why</link>
<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;hr&gt;
&lt;h3 id=&quot;what-s-different-between-the-free-snippets-and-the-toolkit&quot;&gt;What's Different Between the Free Snippets and the Toolkit&lt;/h3&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;hr&gt;
&lt;h3 id=&quot;what-s-in-the-box&quot;&gt;What's in the Box&lt;/h3&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 &quot;best practice&quot; without the consequence attached is advice that gets skipped.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-9&quot;&gt;Why $9&lt;/h3&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 &quot;starter tier&quot; 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;hr&gt;
&lt;h3 id=&quot;who-this-is-for&quot;&gt;Who This Is For&lt;/h3&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 &quot;why&quot; 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;hr&gt;
&lt;h3 id=&quot;every-script-passes-shellcheck-at-zero-warnings&quot;&gt;Every Script Passes ShellCheck at Zero Warnings&lt;/h3&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 &quot;a few style notes we decided were fine.&quot; 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;hr&gt;
&lt;h3 id=&quot;the-link&quot;&gt;The Link&lt;/h3&gt;
&lt;p&gt;→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/starter-kit&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://m.do.co/c/7a196437764c&quot;&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/20082/i-packaged-the-scripts-i-copy-to-every-new-server-into-a-9-toolkit-heres-whats-in-it-and-why</guid>
<pubDate>Tue, 09 Jun 2026 15:43:01 +0000</pubDate>
</item>
<item>
<title>6 small things we shipped across the BashSnippets tools this week</title>
<link>https://coderlegion.com/20000/6-small-things-we-shipped-across-the-bashsnippets-tools-this-week</link>
<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 &quot;does this actually help.&quot;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-bash-boilerplate-generator-trap-amp-cleanup-handler&quot;&gt;1. Bash Boilerplate Generator — Trap &amp;amp; Cleanup Handler&lt;/h3&gt;
&lt;p&gt;There's a now a toggle for trap handling. When on, the generated template includes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cleanup() {
  # Remove temp files, release locks, undo partial changes
  rm -f &quot;$TMPFILE&quot;
}

trap cleanup EXIT INT TERM
&lt;/code&gt;&lt;/pre&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 &quot;runs fine when nothing goes wrong&quot; from &quot;also recovers gracefully when something does.&quot; 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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-boilerplate-generator&quot;&gt;bashsnippets.xyz/tools/bash-boilerplate-generator&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-bash-exit-code-lookup-export-as-case-statement&quot;&gt;2. Bash Exit Code Lookup — Export as &lt;code&gt;case&lt;/code&gt; Statement&lt;/h3&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 (&quot;command found but not executable — check permissions&quot;), now you have to translate that into:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;case $? in
  0)   echo &quot;Success&quot; ;;
  1)   echo &quot;General error&quot; ;;
  126) echo &quot;Command found but not executable — check file permissions&quot; ;;  # ← the one you just looked up
  127) echo &quot;Command not found — check PATH or typo&quot; ;;
  *)   echo &quot;Unknown exit code: $?&quot; ;;
esac
&lt;/code&gt;&lt;/pre&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-exit-code-lookup&quot;&gt;bashsnippets.xyz/tools/bash-exit-code-lookup&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-cron-job-builder-next-5-run-times&quot;&gt;3. Cron Job Builder — Next 5 Run Times&lt;/h3&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;/p&gt;
&lt;pre&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;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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/cron-job-builder&quot;&gt;bashsnippets.xyz/tools/cron-job-builder&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-chmod-calculator-world-writable-warning-and-live-symbolic-mirror&quot;&gt;4. Chmod Calculator — World-Writable Warning and Live Symbolic Mirror&lt;/h3&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 &quot;others&quot; 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 &quot;quick fix&quot; for permission errors more times than I can count. It works immediately, which is why people do it. The fact that it means &quot;literally every user and process on this system can write to this file&quot; 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 &quot;any user on the system can modify this&quot; 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;/p&gt;
&lt;pre&gt;&lt;code&gt;Octal:    chmod 755
Symbolic: chmod u=rwx,go=rx
&lt;/code&gt;&lt;/pre&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 &quot;more correct&quot; — 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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/chmod-permissions-builder&quot;&gt;bashsnippets.xyz/tools/chmod-permissions-builder&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-path-debugger-duplicate-entry-detector&quot;&gt;5. PATH Debugger — Duplicate Entry Detector&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Generated dedup command:
export PATH=$(echo &quot;$PATH&quot; | tr ':' '\n' | awk '!seen[$0]++' | tr '\n' ':' | sed 's/:$//')
&lt;/code&gt;&lt;/pre&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/path-debugger&quot;&gt;bashsnippets.xyz/tools/path-debugger&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-shellcheck-error-decoder-inline-script-scanner&quot;&gt;6. ShellCheck Error Decoder — Inline Script Scanner&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Install
apt install shellcheck       # Debian/Ubuntu
brew install shellcheck      # macOS

# Run
shellcheck yourscript.sh
&lt;/code&gt;&lt;/pre&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/shellcheck-error-decoder&quot;&gt;bashsnippets.xyz/tools/shellcheck-error-decoder&lt;/a&gt;&lt;/p&gt;
&lt;hr&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools&quot;&gt;bashsnippets.xyz/tools&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/20000/6-small-things-we-shipped-across-the-bashsnippets-tools-this-week</guid>
<pubDate>Tue, 09 Jun 2026 03:47:42 +0000</pubDate>
</item>
<item>
<title>I was handed a server with no docs and no idea what it was listening on. One command fixed that.</title>
<link>https://coderlegion.com/19992/i-was-handed-a-server-with-no-docs-and-no-idea-what-it-was-listening-on-one-command-fixed-that</link>
<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 &quot;here's what's running and why.&quot; Just a root password in a LastPass share and a Slack message that said &quot;it hosts our web app, let us know if you need anything.&quot;&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 &quot;inherited server&quot; 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;hr&gt;
&lt;h3 id=&quot;the-command&quot;&gt;The Command&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ss -tlnp
&lt;/code&gt;&lt;/pre&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;code&gt;-l&lt;/code&gt; — listening sockets only (filters out ESTABLISHED, TIME_WAIT, everything else)&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;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;/p&gt;
&lt;pre&gt;&lt;code&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:((&quot;sshd&quot;,pid=1234,fd=3))
LISTEN  0       511     0.0.0.0:80           0.0.0.0:*          users:((&quot;nginx&quot;,pid=5678,fd=6))
LISTEN  0       511     0.0.0.0:443          0.0.0.0:*          users:((&quot;nginx&quot;,pid=5678,fd=7))
LISTEN  0       128     0.0.0.0:5432         0.0.0.0:*          users:((&quot;postgres&quot;,pid=9012,fd=5))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Port 22 — SSH. Expected.&lt;br&gt;
Port 80, 443 — nginx. Expected.&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 &quot;strong password&quot; is not a substitute for &quot;not exposed to the internet.&quot; 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;hr&gt;
&lt;h3 id=&quot;what-the-output-is-actually-telling-you&quot;&gt;What the Output Is Actually Telling You&lt;/h3&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;/p&gt;
&lt;pre&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;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;/p&gt;
&lt;pre&gt;&lt;code&gt;listen_addresses = 'localhost'
&lt;/code&gt;&lt;/pre&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;hr&gt;
&lt;h3 id=&quot;why-ss-instead-of-netstat&quot;&gt;Why &lt;code&gt;ss&lt;/code&gt; Instead of &lt;code&gt;netstat&lt;/code&gt;&lt;/h3&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;hr&gt;
&lt;h3 id=&quot;the-process-name-caveat&quot;&gt;The Process Name Caveat&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ss -tlnp
&lt;/code&gt;&lt;/pre&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;hr&gt;
&lt;h3 id=&quot;the-lsof-alternative&quot;&gt;The &lt;code&gt;lsof&lt;/code&gt; Alternative&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code&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;/code&gt;&lt;/pre&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;hr&gt;
&lt;h3 id=&quot;what-i-actually-do-with-ports-i-can-t-identify&quot;&gt;What I Actually Do With Ports I Can't Identify&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. Find the full command that started the process
ps aux | grep &amp;lt;PID&amp;gt;

# 2. Check what package installed the binary
dpkg -S /path/to/binary    # Debian/Ubuntu
rpm -qf /path/to/binary    # RHEL/CentOS

# 3. Check what the process has open (files, connections, everything)
sudo lsof -p &amp;lt;PID&amp;gt;
&lt;/code&gt;&lt;/pre&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;hr&gt;
&lt;h3 id=&quot;building-this-into-your-server-checklist&quot;&gt;Building This Into Your Server Checklist&lt;/h3&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 &quot;for convenience&quot; 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;hr&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/list-open-ports-linux&quot;&gt;bashsnippets.xyz/snippets/list-open-ports-linux&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19992/i-was-handed-a-server-with-no-docs-and-no-idea-what-it-was-listening-on-one-command-fixed-that</guid>
<pubDate>Tue, 09 Jun 2026 00:02:18 +0000</pubDate>
</item>
<item>
<title>certbot had been auto-renewing my certs for two years. Until it wasn't.</title>
<link>https://coderlegion.com/19878/certbot-had-been-auto-renewing-my-certs-for-two-years-until-it-wasnt</link>
<description>&lt;p&gt;certbot had been running quietly on my server for almost two years without a single issue.&lt;/p&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 &quot;solved&quot; 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 &quot;connection is slow.&quot; Not a timeout. The full-page block. Chrome's red lock icon. &quot;Your connection is not private.&quot; 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 &quot;hey, your SSL is broken&quot; 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 &quot;you have 18 days left — go fix this.&quot;&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;hr&gt;
&lt;h3 id=&quot;the-core-command&quot;&gt;The Core Command&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;echo | openssl s_client -connect yoursite.com:443 -servername yoursite.com \
  2&amp;gt;/dev/null | openssl x509 -noout -enddate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run that and you get back something like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;notAfter=Aug 14 12:00:00 2026 GMT
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;EXPIRY=$(echo | openssl s_client -connect yoursite.com:443 -servername yoursite.com \
  2&amp;gt;/dev/null | openssl x509 -noout -enddate 2&amp;gt;/dev/null | cut -d= -f2)

DAYS=$(( ($(date -d &quot;$EXPIRY&quot; +%s) - $(date +%s)) / 86400 ))

echo &quot;$DAYS days remaining on SSL cert&quot;
&lt;/code&gt;&lt;/pre&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;hr&gt;
&lt;h3 id=&quot;three-non-obvious-things-i-ran-into&quot;&gt;Three Non-Obvious Things I Ran Into&lt;/h3&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 &quot;everything is fine&quot; 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 &quot;%b %d %T %Y %Z&quot; &quot;$EXPIRY&quot; +%s&lt;/code&gt; instead of &lt;code&gt;date -d &quot;$EXPIRY&quot; +%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;hr&gt;
&lt;h3 id=&quot;the-full-script&quot;&gt;The Full Script&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash

CHECK=&quot;✓&quot;
CROSS=&quot;✗&quot;

# --- Configuration ---
DOMAINS=(
  &quot;yourdomain.com&quot;
  &quot;anotherdomain.com&quot;
)
WARN_DAYS=30      # Alert if fewer than this many days remain
PORT=443

# --- Check each domain ---
for DOMAIN in &quot;${DOMAINS[@]}&quot;; do
  EXPIRY=$(echo | openssl s_client \
    -connect &quot;${DOMAIN}:${PORT}&quot; \
    -servername &quot;$DOMAIN&quot; \
    2&amp;gt;/dev/null | openssl x509 -noout -enddate 2&amp;gt;/dev/null | cut -d= -f2)

  if [[ -z &quot;$EXPIRY&quot; ]]; then
    echo &quot;$CROSS Could not retrieve cert for $DOMAIN — is the server reachable?&quot;
    continue
  fi

  EXPIRY_EPOCH=$(date -d &quot;$EXPIRY&quot; +%s 2&amp;gt;/dev/null)
  NOW_EPOCH=$(date +%s)
  DAYS=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

  if [[ &quot;$DAYS&quot; -lt 0 ]]; then
    echo &quot;$CROSS $DOMAIN — CERT EXPIRED ${DAYS#-} days ago&quot;
  elif [[ &quot;$DAYS&quot; -lt &quot;$WARN_DAYS&quot; ]]; then
    echo &quot;$CROSS $DOMAIN — WARNING: $DAYS days remaining (expires $EXPIRY)&quot;
  else
    echo &quot;$CHECK $DOMAIN — OK: $DAYS days remaining&quot;
  fi
done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sample output with two domains:&lt;/p&gt;
&lt;pre&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;hr&gt;
&lt;h3 id=&quot;why-30-days-as-the-threshold&quot;&gt;Why 30 Days as the Threshold&lt;/h3&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;hr&gt;
&lt;h3 id=&quot;adding-an-email-alert&quot;&gt;Adding an Email Alert&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;if [[ &quot;$DAYS&quot; -lt &quot;$WARN_DAYS&quot; ]]; then
  echo &quot;SSL cert for $DOMAIN expires in $DAYS days&quot; | \
    mail -s &quot;CERT EXPIRY WARNING: $DOMAIN&quot; *Emails are not allowed*
fi
&lt;/code&gt;&lt;/pre&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;hr&gt;
&lt;h3 id=&quot;the-cron-setup&quot;&gt;The Cron Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;0 8 * * * /home/user/check-ssl-expiry.sh &amp;gt;&amp;gt; /var/log/ssl-check.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&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;hr&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/check-ssl-certificate-expiry&quot;&gt;bashsnippets.xyz/snippets/check-ssl-certificate-expiry&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz&quot;&gt;bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19878/certbot-had-been-auto-renewing-my-certs-for-two-years-until-it-wasnt</guid>
<pubDate>Sun, 07 Jun 2026 05:04:36 +0000</pubDate>
</item>
<item>
<title>set -euo pipefail — the Line That Would Have Saved Me from Deleting the Wrong Directory</title>
<link>https://coderlegion.com/19616/set-euo-pipefail-the-line-that-would-have-saved-me-from-deleting-the-wrong-directory</link>
<description>&lt;p&gt;Here's a script that will ruin your day:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
cd /nonexistent/folder
rm -rf *
echo &quot;Done&quot;
&lt;/code&gt;&lt;/pre&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 &quot;Done.&quot; 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;hr&gt;
&lt;h3 id=&quot;the-template&quot;&gt;The Template&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
set -euo pipefail

trap 'echo &quot;Error on line $LINENO — script stopped.&quot; &amp;gt;&amp;amp;2' ERR

# Your script starts here
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add this to the top of every script you write. Every single one. No exceptions.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-each-flag-does&quot;&gt;What Each Flag Does&lt;/h3&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;BACKUP_DIR=&quot;&quot;  # Oops, forgot to set this
rm -rf &quot;$BACKUP_DIR&quot;/*
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat nonexistent-file.txt | grep &quot;pattern&quot; | wc -l
&lt;/code&gt;&lt;/pre&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;hr&gt;
&lt;h3 id=&quot;the-trap-line&quot;&gt;The &lt;code&gt;trap&lt;/code&gt; Line&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;trap 'echo &quot;Error on line $LINENO — script stopped.&quot; &amp;gt;&amp;amp;2' ERR
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells bash: &quot;When an error happens, before you exit, run this command.&quot; 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, &quot;it failed somewhere&quot; is useless. &quot;Error on line 47&quot; is actionable.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-real-world-difference&quot;&gt;The Real-World Difference&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Without error handling:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
cd /tmp/build
make
make install
echo &quot;Build complete&quot;
&lt;/code&gt;&lt;/pre&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 &quot;Build complete&quot; regardless.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;With error handling:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
set -euo pipefail
trap 'echo &quot;Error on line $LINENO&quot; &amp;gt;&amp;amp;2' ERR

cd /tmp/build
make
make install
echo &quot;Build complete&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;cd&lt;/code&gt; fails, the script stops and prints &quot;Error on line 5.&quot; If &lt;code&gt;make&lt;/code&gt; fails, same thing. &lt;code&gt;echo &quot;Build complete&quot;&lt;/code&gt; only runs if every single command before it succeeded.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-one-gotcha&quot;&gt;The One Gotcha&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;set -e&lt;/code&gt; changes how you write conditional logic. This fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;set -e
grep &quot;pattern&quot; file.txt  # exits 1 if pattern not found
echo &quot;This never runs&quot;
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;set -e
if grep -q &quot;pattern&quot; file.txt; then
  echo &quot;Found it&quot;
else
  echo &quot;Not found&quot;
fi
&lt;/code&gt;&lt;/pre&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;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep &quot;pattern&quot; file.txt || true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; means &quot;if grep fails, run &lt;code&gt;true&lt;/code&gt; instead&quot; — 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;hr&gt;
&lt;h3 id=&quot;where-to-go-from-here&quot;&gt;Where to Go from Here&lt;/h3&gt;
&lt;p&gt;Every script on &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/&quot;&gt;BashSnippets.xyz&lt;/a&gt; uses &lt;code&gt;set -euo pipefail&lt;/code&gt; and the &lt;code&gt;CHECK=&quot;✓&quot;&lt;/code&gt; / &lt;code&gt;CROSS=&quot;✗&quot;&lt;/code&gt; pattern. If you want a head start, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/tools/bash-boilerplate-generator.html&quot;&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;hr&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 rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/bash-error-handling.html&quot;&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>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19616/set-euo-pipefail-the-line-that-would-have-saved-me-from-deleting-the-wrong-directory</guid>
<pubDate>Wed, 03 Jun 2026 21:32:20 +0000</pubDate>
</item>
<item>
<title>grep -rn Changed How I Debug. I Use It Before I Open a Single File.</title>
<link>https://coderlegion.com/19376/grep-rn-changed-how-i-debug-i-use-it-before-i-open-a-single-file</link>
<description>&lt;p&gt;I used to open files to search for things.&lt;/p&gt;
&lt;p&gt;A config had the wrong database host somewhere. I knew it was in one of the files in &lt;code&gt;/etc/nginx/&lt;/code&gt; but I didn't know which one. So I opened &lt;code&gt;nginx.conf&lt;/code&gt;. Searched. Not there. Opened &lt;code&gt;sites-available/default&lt;/code&gt;. Searched. Not there either. Opened three more files. Found it in the fourth one.&lt;/p&gt;
&lt;p&gt;That took about 4 minutes. The grep command that does the same thing takes less than a second:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;db.old-host.com&quot; /etc/nginx/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every file. Every subdirectory. Exact filename. Exact line number. Done.&lt;/p&gt;
&lt;p&gt;I genuinely don't understand why it took me so long to make this my default workflow instead of manually opening files like it's 2004.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-script&quot;&gt;The Script&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
# Search files for text — grep reference

KEYWORD=&quot;TODO&quot;          # ← what to search for
SEARCH_DIR=&quot;$HOME&quot;      # ← where to search
FILE_TYPE=&quot;*.txt&quot;       # ← file types to include

echo &quot;Searching for '$KEYWORD' in $SEARCH_DIR...&quot;
echo &quot;────────────────────────────────────────&quot;

grep -rn &quot;$KEYWORD&quot; &quot;$SEARCH_DIR&quot; \
  --include=&quot;$FILE_TYPE&quot; \
  --color=auto

COUNT=$(grep -rc &quot;$KEYWORD&quot; &quot;$SEARCH_DIR&quot; \
  --include=&quot;$FILE_TYPE&quot; 2&amp;gt;/dev/null \
  | grep -v &quot;:0$&quot; | wc -l)

echo &quot;────────────────────────────────────────&quot;
echo &quot;Found in $COUNT file(s)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But honestly, you'll use the raw command more than the script. The script is a reference. The command is the weapon.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-flags-that-actually-matter&quot;&gt;The Flags That Actually Matter&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;grep -r&lt;/code&gt;&lt;/strong&gt; — recursive. Searches every file in the directory and all subdirectories. Without this, grep only searches the one file you hand it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;grep -n&lt;/code&gt;&lt;/strong&gt; — line numbers. Instead of just showing the matching line, it shows &lt;code&gt;filename:42: the matching line&lt;/code&gt;. Now you know exactly where to look.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;grep -rn&lt;/code&gt;&lt;/strong&gt; — both together. This is the one I type the most. &lt;code&gt;grep -rn &quot;ERROR&quot; /var/log/&lt;/code&gt; and I immediately see every error across every log file with the exact line number.&lt;/p&gt;
&lt;p&gt;*&lt;em&gt;`--include=&quot;&lt;/em&gt;.py&quot;&lt;code&gt;** — filter by file type. If you only want to search Python files, or only &lt;/code&gt;.conf&lt;code&gt; files, or only &lt;/code&gt;.html` files, this prevents grep from wasting time on binary files and irrelevant formats.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;--color=auto&lt;/code&gt;&lt;/strong&gt; — highlights the matched keyword in the output. When you're scanning 50 results, the color makes the keyword jump out instantly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;-i&lt;/code&gt;&lt;/strong&gt; — case-insensitive. &lt;code&gt;grep -rni &quot;error&quot; /var/log/&lt;/code&gt; catches &quot;Error&quot;, &quot;ERROR&quot;, &quot;error&quot;, and any other capitalization.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;-l&lt;/code&gt;&lt;/strong&gt; — filenames only. When you don't need to see the matching lines, just which files contain the keyword. &lt;code&gt;grep -rl &quot;TODO&quot; ./src/&lt;/code&gt; gives you a clean list of files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;-c&lt;/code&gt;&lt;/strong&gt; — count matches per file. &lt;code&gt;grep -rc &quot;TODO&quot; ./src/&lt;/code&gt; shows &lt;code&gt;filename:3&lt;/code&gt; meaning 3 matches in that file.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-combinations-i-use-every-week&quot;&gt;The Combinations I Use Every Week&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Find every TODO in a project:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;TODO&quot; ./src/ --include=&quot;*.js&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Find which config file has a specific setting:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;max_connections&quot; /etc/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Find a function definition across a codebase:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;def process_order&quot; ./app/ --include=&quot;*.py&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Find IP addresses in log files:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;192.168&quot; /var/log/ --include=&quot;*.log&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Find files that contain a string but don't care about the matching lines:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rl &quot;database_url&quot; ./config/
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;grep-vs-your-editor-s-search&quot;&gt;grep vs. Your Editor's Search&lt;/h3&gt;
&lt;p&gt;Your editor's search (Ctrl+Shift+F in VS Code, Cmd+Shift+F on Mac) does basically the same thing — but only for files that are open in your project.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;grep&lt;/code&gt; works on any directory on any machine, including servers where you only have SSH access and no editor at all. When you're debugging a production issue over SSH at 11 PM, &lt;code&gt;grep -rn&lt;/code&gt; is the fastest path from &quot;I know the error is in here somewhere&quot; to the exact line.&lt;/p&gt;
&lt;p&gt;It's also faster on large directories because it doesn't need to index anything first. It just reads files sequentially and matches the pattern.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;one-gotcha-worth-knowing&quot;&gt;One Gotcha Worth Knowing&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;grep&lt;/code&gt; on binary files produces garbage output. If you're searching a directory that contains images, compiled files, or databases, add &lt;code&gt;--binary-files=without-match&lt;/code&gt; to skip them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;search_term&quot; /some/dir/ --binary-files=without-match
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or use &lt;code&gt;--include&lt;/code&gt; to limit to specific text file types, which solves the same problem more precisely.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Full script, flag cheat sheet, and more combinations:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://bashsnippets.xyz/snippets/search-files-for-text-grep.html&quot;&gt;bashsnippets.xyz/snippets/search-files-for-text-grep.html&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If you want to quickly look up the flags without reading a man page, I keep a reference table on the snippet page with every flag I've actually used in production.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/19376/grep-rn-changed-how-i-debug-i-use-it-before-i-open-a-single-file</guid>
<pubDate>Mon, 01 Jun 2026 01:41:05 +0000</pubDate>
</item>
</channel>
</rss>