<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anguishe</title>
    <description>The latest articles on DEV Community by Anguishe (@bashsnippets).</description>
    <link>https://dev.to/bashsnippets</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3909567%2F885dee1e-f72c-48d7-965f-91ee8ade012a.jpeg</url>
      <title>DEV Community: Anguishe</title>
      <link>https://dev.to/bashsnippets</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bashsnippets"/>
    <language>en</language>
    <item>
      <title>Restart=always Did Nothing. The Service Never Exited.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 12 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/restartalways-did-nothing-the-service-never-exited-7di</link>
      <guid>https://dev.to/bashsnippets/restartalways-did-nothing-the-service-never-exited-7di</guid>
      <description>&lt;p&gt;On August 6 my laptop booted to a blank terminal. The display manager, &lt;code&gt;lightdm&lt;/code&gt;, has &lt;code&gt;Restart=always&lt;/code&gt; in its unit, and &lt;code&gt;systemctl status lightdm&lt;/code&gt; said &lt;code&gt;active (running)&lt;/code&gt;. As far as systemd was concerned, nothing was down.&lt;/p&gt;

&lt;p&gt;What had happened was a race. Xorg entered the NVIDIA driver's init routine about 140 ms before the &lt;code&gt;/dev/nvidia*&lt;/code&gt; device nodes existed, because another GPU service, also &lt;code&gt;Restart=always&lt;/code&gt; and started one second earlier, was competing for the card. Xorg never came back. Fourteen hours later it was still there in state &lt;code&gt;S&lt;/code&gt;, one thread, 20 ms of CPU used in total, its log file cut off mid-sentence with no error line.&lt;/p&gt;

&lt;p&gt;I had trusted that restart policy without reading what it promises. &lt;code&gt;Restart=always&lt;/code&gt; restarts a process after it exits. Mine never exited. A cron job checking &lt;code&gt;systemctl is-active lightdm&lt;/code&gt; every minute would have said "fine" all night. &lt;code&gt;sudo systemctl restart lightdm&lt;/code&gt; brought the desktop back in three seconds, and the real fix turned out to be ordering, &lt;code&gt;nvidia-persistenced&lt;/code&gt; so the device nodes exist before anything asks for them, not a restart policy at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Down" is three different states
&lt;/h2&gt;

&lt;p&gt;The mistake is treating "down" as one condition. It is at least three, and they look identical from a chair across the room. A process that crashed or was killed shows &lt;code&gt;failed&lt;/code&gt;, and a restart is correct. A service someone stopped on purpose shows &lt;code&gt;inactive&lt;/code&gt; with &lt;code&gt;Result=success&lt;/code&gt;, and a restart fights the human doing maintenance. A process that is running but not answering shows &lt;code&gt;active (running)&lt;/code&gt;, and no restart ever happens, because nobody asked. My August 6 was the third kind, and the standard "restart it if it's down" watchdog only handles the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the state before you touch it
&lt;/h2&gt;

&lt;p&gt;Most watchdogs start from &lt;code&gt;systemctl is-active&lt;/code&gt;. I re-ran it on the same laptop today, systemd 261, with a deliberate typo in the third name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;u &lt;span class="k"&gt;in &lt;/span&gt;cron lightdm crno&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;   &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-8s is-active=%-9s rc='&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl is-active &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;   systemctl is-active &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;done
&lt;/span&gt;cron     is-active&lt;span class="o"&gt;=&lt;/span&gt;active    &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
lightdm  is-active&lt;span class="o"&gt;=&lt;/span&gt;active    &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
crno     is-active&lt;span class="o"&gt;=&lt;/span&gt;inactive  &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 0 is running. Exit code 3 is a real unit that is not running. Exit code 4 is a unit that does not exist, and &lt;code&gt;is-active&lt;/code&gt; prints the same word, &lt;code&gt;inactive&lt;/code&gt;, for 3 and 4. A watchdog written as &lt;code&gt;if ! systemctl is-active --quiet "$SERVICE"&lt;/code&gt; treats the typo as an outage, tries to start &lt;code&gt;crno&lt;/code&gt; every minute, fails every minute, and alerts every minute until someone reads the message closely enough to spot the swapped letters.&lt;/p&gt;

&lt;p&gt;The command a script should use is &lt;code&gt;systemctl show&lt;/code&gt;, because each field comes back as one clean line with no colour and no localisation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;systemctl show lightdm &lt;span class="nt"&gt;-p&lt;/span&gt; Restart,RestartUSec,NRestarts,ActiveState,SubState,Result,StartLimitBurst,StartLimitIntervalUSec
&lt;span class="nv"&gt;Restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;always
&lt;span class="nv"&gt;ActiveState&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;active
&lt;span class="nv"&gt;SubState&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;running
&lt;span class="nv"&gt;StartLimitIntervalUSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10s
&lt;span class="nv"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;span class="nv"&gt;Restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;always
&lt;span class="nv"&gt;RestartUSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100ms
&lt;span class="nv"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;success
&lt;span class="nv"&gt;NRestarts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is my lightdm today: &lt;code&gt;Restart=always&lt;/code&gt;, 100 ms between attempts, zero restarts since boot. &lt;code&gt;LoadState&lt;/code&gt; separates the typo from the outage. &lt;code&gt;Result&lt;/code&gt; tells you why a unit is in its state, and &lt;code&gt;NRestarts&lt;/code&gt; climbing means systemd is already doing the job and your watchdog is late.&lt;/p&gt;

&lt;p&gt;The last two lines are the start-limit trap. A unit started more than five times inside ten seconds, which is what a short &lt;code&gt;RestartSec&lt;/code&gt; does to a daemon that crashes on startup, goes to &lt;code&gt;failed&lt;/code&gt; with &lt;code&gt;Result=start-limit-hit&lt;/code&gt;, and from then on &lt;code&gt;systemctl start&lt;/code&gt; refuses until someone runs &lt;code&gt;systemctl reset-failed&lt;/code&gt;. A watchdog that does not know this reports "restart failed" forever. One that resets it unconditionally every minute has switched off systemd's crash-loop protection and replaced it with its own crash loop, with an email each cycle. The guide's version resets a bounded number of times per outage, then stops and tells a human.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it is running but not answering
&lt;/h2&gt;

&lt;p&gt;That leaves the case I actually had. No state check sees it, because the state is correct: the process exists and is running. The only way to know a service answers is to ask it the way a client would, with a timeout on the question, for example &lt;code&gt;curl -fsS --max-time 5 http://127.0.0.1/ &amp;gt;/dev/null&lt;/code&gt; for a web server, or &lt;code&gt;ss -Hltn 'sport = :5432' | grep -q .&lt;/code&gt; for anything that should be listening on a port. A probe without a timeout is one more hung process on the box, and the next cron tick starts another.&lt;/p&gt;

&lt;p&gt;When the probe fails on an &lt;code&gt;active&lt;/code&gt; unit, &lt;code&gt;systemctl restart&lt;/code&gt; is the treatment, with two costs. Restart is stop plus start, so a process that ignores SIGTERM holds it for &lt;code&gt;TimeoutStopSec&lt;/code&gt;, 90 seconds by default. And the restart destroys the evidence of what the process was stuck on. A hung process is the only witness to what it was waiting on, so capture its state and its &lt;code&gt;wchan&lt;/code&gt; before anything is killed.&lt;/p&gt;

&lt;p&gt;Fourteen hours of &lt;code&gt;active (running)&lt;/code&gt; and a restart policy that never fired: the policy was working as documented. I had asked it to cover a failure it cannot see.&lt;/p&gt;




&lt;p&gt;Full guide, with the three-state table, the &lt;code&gt;Restart=&lt;/code&gt; values and when each fires, the start-limit trap, and a cron watchdog that probes, honours a maintenance flag and alerts once per outage: &lt;a href="https://bashsnippets.xyz/guides/auto-restart-linux-service" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/guides/auto-restart-linux-service&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The watchdog script on its own is &lt;a href="https://bashsnippets.xyz/snippets/service-watchdog" rel="noopener noreferrer"&gt;Service Watchdog&lt;/a&gt;, and what to capture before the restart wipes it out is in &lt;a href="https://bashsnippets.xyz/guides/diagnose-a-hung-process" rel="noopener noreferrer"&gt;Diagnosing a Hung Process&lt;/a&gt;. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>ss Showed 0.0.0.0:3000 and No Process Name. It Was a Docker Container on Every Interface.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 11 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/ss-showed-00003000-and-no-process-name-it-was-a-docker-container-on-every-interface-36gp</link>
      <guid>https://dev.to/bashsnippets/ss-showed-00003000-and-no-process-name-it-was-a-docker-container-on-every-interface-36gp</guid>
      <description>&lt;p&gt;The dev server for my site came up on port 3001 for months. Next.js wants 3000, finds it taken, moves up one, and says nothing worth reading. When I finally asked who had 3000, this is what &lt;code&gt;ss&lt;/code&gt; told me as a normal user. It is the opening run of the open-ports guide on the site, published 2026-09-01:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ss -ltnp 'sport = :3000'
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      4096         0.0.0.0:3000       0.0.0.0:*
LISTEN 0      4096            [::]:3000          [::]:*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two sockets, every IPv4 interface and every IPv6 interface, and an empty &lt;code&gt;Process&lt;/code&gt; column. One more flag, &lt;code&gt;ss -ltnpe&lt;/code&gt;, printed the socket's cgroup, &lt;code&gt;cgroup:/system.slice/docker.service&lt;/code&gt;, and one &lt;code&gt;docker ps&lt;/code&gt; later the owner had a name: a &lt;code&gt;docker-proxy&lt;/code&gt; publishing an Open WebUI container's port 8080 as host port 3000. It had been there since spring.&lt;/p&gt;

&lt;p&gt;The container is not the part that bothers me. I had read output like that before, seen no name, and moved on. Both answers were on screen. The address column said who could reach it, and the empty column said I was not allowed to see the owner. I read neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  ss prints facts, not verdicts
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Local Address&lt;/code&gt; column is the reach. &lt;code&gt;127.0.0.1&lt;/code&gt; and &lt;code&gt;[::1]&lt;/code&gt; are this machine only. &lt;code&gt;0.0.0.0&lt;/code&gt; is every IPv4 address the host has, LAN, VPN and Docker bridge included, and &lt;code&gt;[::]&lt;/code&gt; is the IPv6 twin. An empty &lt;code&gt;Process&lt;/code&gt; column without root does not mean "no process". It means the socket belongs to another user, and &lt;code&gt;ss&lt;/code&gt; only names your own. The &lt;code&gt;-e&lt;/code&gt; flag prints the owning cgroup instead, which is the systemd unit, and it needs no root.&lt;/p&gt;

&lt;p&gt;A published container port has one more twist, and this part is documented Docker behaviour rather than something I tested here: Docker routes published ports through its own iptables chains before the host's &lt;code&gt;ufw&lt;/code&gt; rules see the packet, so a &lt;code&gt;ufw deny 3000&lt;/code&gt; does not cover them. Docker's documentation covers it under Docker and ufw. The guide's fix was to publish on loopback, &lt;code&gt;-p 127.0.0.1:3000:8080&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of that is hard once you know it. Holding all of it in your head while reading a long &lt;code&gt;ss&lt;/code&gt; listing is where it goes wrong, so I built a page that does the reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the explainer does with a paste
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/tools/open-ports-explainer" rel="noopener noreferrer"&gt;Open Ports Explainer&lt;/a&gt; takes the output of &lt;code&gt;ss -tulpn&lt;/code&gt;, &lt;code&gt;ss -ltnpe&lt;/code&gt;, &lt;code&gt;netstat -tulpn&lt;/code&gt; or &lt;code&gt;lsof -i -P -n&lt;/code&gt; and works out which one it is on its own. It parses in the browser, never connects to a port, and has &lt;strong&gt;Redact addresses&lt;/strong&gt; ticked by default, so LAN and public addresses are masked on screen and in the share link. A &lt;strong&gt;Load example&lt;/strong&gt; button fills it with a real &lt;code&gt;ss -tulpn&lt;/code&gt; listing if you want to see it work first.&lt;/p&gt;

&lt;p&gt;Every listener becomes a card: the address, the reach scope in words, the owner (from the &lt;code&gt;Process&lt;/code&gt; column, or the systemd unit when you ran &lt;code&gt;ss -e&lt;/code&gt; without root), what usually lives on that port, flags ranked by severity, and the next command to run. A share link carries only the normalised rows in the URL hash, with PIDs dropped, and &lt;strong&gt;Copy baseline CSV&lt;/strong&gt; writes the listing in the shape the ports-audit script diffs against.&lt;/p&gt;

&lt;p&gt;Tonight I ran the guide's two lines back through the explainer's own parser, the same TypeScript the page runs, with a short Node wrapper that prints each card as text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0.0.0:3000  +  [::]:3000
  All interfaces, IPv4 and IPv6 · owner not shown
  dev server / Grafana. Node, Next.js or Rails dev server by default; Grafana and Open WebUI also use 3000.
  WARN A development server reachable from your network. Dev servers are not built to face other machines.
  INFO No owner shown. Run ss -ltnpe to see the systemd unit, which also tells you whether this is a container.
  NOTE Two sockets, one per address family. Either can close without the other.
  next: ss -ltnp 'sport = :3000'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That INFO line is the step I skipped for months. Given the &lt;code&gt;-e&lt;/code&gt; line instead, the owner column became &lt;code&gt;docker.service&lt;/code&gt;, a second WARN appeared ("A published container port. Docker's own iptables rules route this traffic to the container before ufw's rules see it"), and the next command changed to &lt;code&gt;docker ps --format '{{.Names}}\t{{.Ports}}'&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fresh run, nothing real listening
&lt;/h2&gt;

&lt;p&gt;To check the ranking I made three listeners inside a private network namespace (&lt;code&gt;unshare -rn&lt;/code&gt;, no sudo, gone when the shell exits): Python's &lt;code&gt;http.server&lt;/code&gt; on &lt;code&gt;0.0.0.0:3000&lt;/code&gt;, a bare socket on &lt;code&gt;0.0.0.0:5432&lt;/code&gt; standing in for a database, and one on &lt;code&gt;127.0.0.1:8099&lt;/code&gt;. As root inside the namespace, &lt;code&gt;ss&lt;/code&gt; named every owner. Below it, the explainer's summary line and the first of its three cards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ss -tulpn
Netid State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
tcp   LISTEN 0      128        127.0.0.1:8099      0.0.0.0:*    users:(("python3",pid=612058,fd=3))
tcp   LISTEN 0      5            0.0.0.0:3000      0.0.0.0:*    users:(("python3",pid=612055,fd=3))
tcp   LISTEN 0      128          0.0.0.0:5432      0.0.0.0:*    users:(("python3",pid=612056,fd=3))

3 listeners (3 sockets) · 2 reachable from the network · 2 flagged

0.0.0.0:5432
  All IPv4 interfaces · python3
  PostgreSQL. PostgreSQL server, often a local dev database container.
  CRITICAL A database or cache is listening on every interface. Anyone on your network can try to log in to it.
  next: ss -ltnpe 'sport = :5432'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ss&lt;/code&gt; printed the database row last. The explainer put it first, because a database on a wildcard address outranks a dev server, and the loopback socket got a green OK. It names a port by its usual tenant, so a Python socket on 5432 reads as PostgreSQL. That is the right guess for a scan, and the reason every card ends with a command that checks the owner.&lt;/p&gt;

&lt;p&gt;What it cannot see is your firewall, NAT or cloud security groups, and the page says so under the input box. It tells you who &lt;em&gt;could&lt;/em&gt; reach a socket from where it is bound; whether a packet gets there is a question for &lt;code&gt;nc&lt;/code&gt; from another machine.&lt;/p&gt;

&lt;p&gt;The months of 3001 would have been one paste. The card said "owner not shown" and handed me the command that names the owner.&lt;/p&gt;




&lt;p&gt;Paste your own &lt;code&gt;ss -tulpn&lt;/code&gt; into the Open Ports Explainer: &lt;a href="https://bashsnippets.xyz/tools/open-ports-explainer" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/tools/open-ports-explainer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the commands behind each verdict, including the &lt;code&gt;-e&lt;/code&gt; owner trick and what &lt;code&gt;docker-proxy&lt;/code&gt; hides, the &lt;a href="https://bashsnippets.xyz/guides/open-ports-linux" rel="noopener noreferrer"&gt;open ports guide&lt;/a&gt; pairs with it, and the &lt;a href="https://bashsnippets.xyz/snippets/ports-audit" rel="noopener noreferrer"&gt;ports-audit script&lt;/a&gt; turns the baseline CSV into a nightly alert. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>docker</category>
      <category>bash</category>
    </item>
    <item>
      <title>I Deleted a 44.6 GB File and df Gave Me Back 5 GB. Something Still Had It Open.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 10 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-deleted-a-446-gb-file-and-df-gave-me-back-5-gb-something-still-had-it-open-26j6</link>
      <guid>https://dev.to/bashsnippets/i-deleted-a-446-gb-file-and-df-gave-me-back-5-gb-something-still-had-it-open-26j6</guid>
      <description>&lt;p&gt;On August 28 my laptop's disk sat at 88 %, and I went looking for something big to delete. I found it quickly: a 44.6 GB video intermediate, a rotate-to-vertical export I could rebuild from its source at any time. I deleted it. &lt;code&gt;du&lt;/code&gt; on that folder dropped by 44 GB. &lt;code&gt;df&lt;/code&gt; on the filesystem moved by 5.&lt;/p&gt;

&lt;p&gt;For a few minutes I doubted the filesystem. The real answer was a process. XFCE's thumbnailer daemon, &lt;code&gt;tumblerd&lt;/code&gt;, had opened the file to generate a preview and never closed it. &lt;code&gt;pkill -x tumblerd&lt;/code&gt; gave the space back instantly, and the daemon respawns on its own the next time something needs a thumbnail, so killing it cost nothing.&lt;/p&gt;

&lt;p&gt;That gap between &lt;code&gt;du&lt;/code&gt; and &lt;code&gt;df&lt;/code&gt; is the whole lesson, and it is the one people hit on servers with log files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why deleting a file can free nothing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rm&lt;/code&gt; does not delete data. It removes a directory entry that points at an inode. The blocks go back to the filesystem only when the inode's link count reaches zero &lt;em&gt;and&lt;/em&gt; no process still has the file open. While one descriptor is open, the file is invisible to &lt;code&gt;ls&lt;/code&gt; and &lt;code&gt;du&lt;/code&gt; and still fully allocated as far as &lt;code&gt;df&lt;/code&gt; is concerned. &lt;code&gt;du&lt;/code&gt; counts names that are gone; &lt;code&gt;df&lt;/code&gt; counts blocks that are actually free. When they disagree, the difference is deleted-but-open files.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lsof +L1&lt;/code&gt; lists them: open files with a link count below one. Without root it shows only your own processes; on my machine today it shows nothing more alarming than a few of PipeWire's memory-backed buffers. On a server, run it as root and the culprit usually has a name, a PID and a size.&lt;/p&gt;

&lt;p&gt;There is a second reason &lt;code&gt;df&lt;/code&gt; can refuse to move: hard links. Removing one name of a file that has another name frees nothing, because the link count is still above zero. &lt;code&gt;stat -c %h file&lt;/code&gt; prints the count before you rely on the delete.&lt;/p&gt;

&lt;p&gt;And on a server, when the file is a log that a live service is still writing, &lt;code&gt;rm&lt;/code&gt; is the wrong verb entirely. Truncate it in place with &lt;code&gt;: &amp;gt; access.log&lt;/code&gt;: the inode and every open descriptor survive, the length drops to zero, and the space comes back without restarting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding what ate the disk
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;df&lt;/code&gt; tells you which filesystem is full. It cannot tell you why, because it reads totals from the filesystem's own bookkeeping. The question "what filled it" is two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The 20 largest files and directories, biggest first&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-ah&lt;/span&gt; / &lt;span class="nt"&gt;--exclude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/proc &lt;span class="nt"&gt;--exclude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/sys &lt;span class="nt"&gt;--exclude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rh&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20

&lt;span class="c"&gt;# Individual files over 500 MB, wherever they hide&lt;/span&gt;
find / &lt;span class="nt"&gt;-xdev&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-size&lt;/span&gt; +500M &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run against my home directory without root, the first one answered in under two minutes: 645 G in total, 430 G of it in one projects folder, and the top of the list was two video projects' raw-footage folders. No mystery left, only decisions about what to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one character that reorders the list
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sort -rh&lt;/code&gt; is doing more work than it looks. I fed the same four &lt;code&gt;du&lt;/code&gt;-style lines to both variants. &lt;code&gt;sort -rn&lt;/code&gt; returned &lt;code&gt;850K&lt;/code&gt;, &lt;code&gt;500M&lt;/code&gt;, &lt;code&gt;13G&lt;/code&gt;, &lt;code&gt;10G&lt;/code&gt;: it reads the leading digits and stops, so 850 beats 500 beats 13, and the smallest item is ranked first. &lt;code&gt;sort -rh&lt;/code&gt; returned &lt;code&gt;13G&lt;/code&gt;, &lt;code&gt;10G&lt;/code&gt;, &lt;code&gt;500M&lt;/code&gt;, &lt;code&gt;850K&lt;/code&gt;, because &lt;code&gt;-h&lt;/code&gt; understands the size suffixes &lt;code&gt;du&lt;/code&gt; prints. One letter separates a ranking from a shuffle, and the wrong one fails quietly on exactly the list you are reading under pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the excludes are not optional
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ls -l /proc/kcore&lt;/code&gt; on my laptop reports a file of 140,737,471,590,400 bytes, which is 128 TiB on a machine with an 868 GB disk. It is not a file; it is the kernel's address space presented as one, and &lt;code&gt;/proc&lt;/code&gt; and &lt;code&gt;/sys&lt;/code&gt; are full of synthesized entries like it. Any size scan that walks them either wastes minutes or reports nonsense, and the &lt;code&gt;find&lt;/code&gt; above adds &lt;code&gt;-xdev&lt;/code&gt; so it stays on one filesystem and never crosses into them or into network mounts.&lt;/p&gt;

&lt;p&gt;The order that works under pressure is &lt;code&gt;df -h&lt;/code&gt; to find the full filesystem, the ranked &lt;code&gt;du&lt;/code&gt; to find what grew, &lt;code&gt;find -size&lt;/code&gt; for the single monster file, and &lt;code&gt;lsof +L1&lt;/code&gt; the moment &lt;code&gt;df&lt;/code&gt; and &lt;code&gt;du&lt;/code&gt; disagree. My 44.6 GB would have taken ten seconds with that last step instead of ten minutes of doubting the filesystem.&lt;/p&gt;




&lt;p&gt;Full script with the exclude patterns, thresholds, and the recent-growth variation: &lt;a href="https://bashsnippets.xyz/snippets/find-large-files-linux" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/find-large-files-linux&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Getting the space back is the recovery half. Never seeing 100 % again is the &lt;a href="https://bashsnippets.xyz/snippets/disk-space-warning" rel="noopener noreferrer"&gt;Disk Space Warning&lt;/a&gt; snippet's job — it alerts at a threshold you pick instead of letting the disk do the alerting — and &lt;a href="https://bashsnippets.xyz/snippets/delete-old-log-files" rel="noopener noreferrer"&gt;Delete Old Log Files&lt;/a&gt; keeps the logs from regrowing behind you. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>I Killed the Parent Process. Its Child Kept the Port.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 05 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-killed-the-parent-process-its-child-kept-the-port-5311</link>
      <guid>https://dev.to/bashsnippets/i-killed-the-parent-process-its-child-kept-the-port-5311</guid>
      <description>&lt;p&gt;&lt;code&gt;EADDRINUSE&lt;/code&gt; after you are sure you stopped the server has a common cause, and I wanted to watch it happen rather than describe it. On my own machine I started a wrapper the way tools like &lt;code&gt;npm run dev&lt;/code&gt; do — a shell that launches the real server as a child and waits on it: &lt;code&gt;bash -c 'python3 -m http.server 8098 &amp;amp; wait'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two processes appeared: the parent &lt;code&gt;bash&lt;/code&gt; and the child &lt;code&gt;python3&lt;/code&gt;. &lt;code&gt;lsof -ti :8098&lt;/code&gt; returned one PID, and it was the child's. I killed the parent, the process I had started and the one most people would find first in &lt;code&gt;ps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The port stayed taken. &lt;code&gt;lsof -ti :8098&lt;/code&gt; still returned the same PID, now an orphan that had been reparented and was listening as if nothing had happened. Killing the thing you launched does not free a port when the thing you launched is not the thing holding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask the socket table, not the process table
&lt;/h2&gt;

&lt;p&gt;The mistake is asking the process table a question that belongs to the socket table. Process names are ambiguous: on a developer machine, &lt;code&gt;ps aux | grep node&lt;/code&gt; returns a crowd of editor helpers, language servers and the &lt;code&gt;grep&lt;/code&gt; itself, none of them labelled with a port. The socket table has exactly one answer per listening port, because a bound port belongs to one process until it lets go.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lsof -ti :PORT&lt;/code&gt; asks that directly. &lt;code&gt;-i&lt;/code&gt; filters by internet address and &lt;code&gt;-t&lt;/code&gt; prints only the PID, because it exists to be piped into something. The first thing to pipe it into is not &lt;code&gt;kill&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;lsof &lt;span class="nt"&gt;-ti&lt;/span&gt; :8098 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1&lt;span class="si"&gt;)&lt;/span&gt;
ps &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nb"&gt;comm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;                        &lt;span class="c"&gt;# look at what it is before you kill it&lt;/span&gt;
&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;                                  &lt;span class="c"&gt;# SIGTERM — a request, not an execution&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="c"&gt;# still alive? force it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my run, &lt;code&gt;ps -p&lt;/code&gt; named the orphan &lt;code&gt;python3&lt;/code&gt;, and the plain &lt;code&gt;kill&lt;/code&gt; was enough: one second later &lt;code&gt;kill -0&lt;/code&gt; found nothing, the port was free, and SIGKILL was never needed.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;ps -p&lt;/code&gt; line is there because discovery answers "which PID", not "safe to kill". On a laptop it is usually your own crashed dev server. On a shared box, the process on 5432 might be a database someone is migrating. Two seconds of reading the name is cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SIGTERM comes first
&lt;/h2&gt;

&lt;p&gt;Plain &lt;code&gt;kill&lt;/code&gt; sends SIGTERM, which the process may catch: flush its buffers, finish in-flight requests, remove its pidfile, close the socket properly. &lt;code&gt;kill -9&lt;/code&gt; sends SIGKILL, which the process never sees; the kernel removes it mid-write if that is where it happened to be. SIGTERM, a pause, and SIGKILL only if something is still alive is the order that loses nothing in the common case and still works in the stubborn one.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;lsof&lt;/code&gt; is not installed — minimal containers and small VPS images often lack it — &lt;code&gt;ss -ltnp&lt;/code&gt; gives the same answer from iproute2, which modern distributions ship everywhere. Without root it names only your own processes; its &lt;code&gt;-e&lt;/code&gt; flag still shows the owning systemd unit for anyone's socket, which is how I once found a forgotten &lt;code&gt;docker-proxy&lt;/code&gt; squatting on port 3000 of my own laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the port is still busy after the kill
&lt;/h2&gt;

&lt;p&gt;Sometimes you kill the right process, &lt;code&gt;lsof&lt;/code&gt; comes back empty, and the bind still fails. Nothing is holding the port; the kernel is. A closed TCP connection lingers in TIME_WAIT for up to a minute to absorb packets still in flight for the old conversation — on my machine at that moment, &lt;code&gt;ss -Htan state time-wait | wc -l&lt;/code&gt; counted two. There is no PID to kill. Servers that set &lt;code&gt;SO_REUSEADDR&lt;/code&gt; on their listening socket can rebind straight away; for those that do not, the fix is to wait a minute, not to reach for a bigger hammer.&lt;/p&gt;

&lt;p&gt;The whole failure comes down to asking the right table. The parent is what you remember starting; the socket knows who is actually there.&lt;/p&gt;




&lt;p&gt;Full script with the port validation, the &lt;code&gt;ss&lt;/code&gt; fallback, and the SIGTERM→SIGKILL escalation loop: &lt;a href="https://bashsnippets.xyz/snippets/kill-process-on-port" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/kill-process-on-port&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When the thing that needs to die has a name instead of a port number, &lt;a href="https://bashsnippets.xyz/snippets/kill-a-process" rel="noopener noreferrer"&gt;Kill a Process&lt;/a&gt; covers the pgrep/pkill side, and once the port is free, &lt;a href="https://bashsnippets.xyz/snippets/check-if-website-is-up" rel="noopener noreferrer"&gt;Check If Website Is Up&lt;/a&gt; confirms whatever replaced it is actually answering. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>One ssh Flag Tells You Whether a Server Still Accepts Passwords</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/one-ssh-flag-tells-you-whether-a-server-still-accepts-passwords-45kn</link>
      <guid>https://dev.to/bashsnippets/one-ssh-flag-tells-you-whether-a-server-still-accepts-passwords-45kn</guid>
      <description>&lt;p&gt;Setting up SSH keys produces a row of green lights. &lt;code&gt;ssh-keygen&lt;/code&gt; prints a fingerprint, &lt;code&gt;ssh-copy-id&lt;/code&gt; reports &lt;code&gt;Number of key(s) added: 1&lt;/code&gt; and exits 0, and the next login skips the password prompt. None of those answers the question the setup was for: does this server still accept passwords?&lt;/p&gt;

&lt;p&gt;There is a one-flag way to ask. &lt;code&gt;ssh -o PreferredAuthentications=none user@host&lt;/code&gt; offers no credentials at all, so the server has to refuse, and its refusal lists every method it would have accepted. I pointed it at a Mac on my home network that has Remote Login switched on, from my Kali laptop. The answer was &lt;code&gt;Permission denied (publickey,password,keyboard-interactive)&lt;/code&gt;. Passwords are open there. The same kind of question put to GitHub's SSH endpoint comes back with &lt;code&gt;publickey&lt;/code&gt; and nothing else. No login attempt, no guessing, two seconds.&lt;/p&gt;

&lt;p&gt;That probe answers what the setup evening's green lights never could, because those lights were measuring something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the exit 0 actually promised
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ssh-copy-id&lt;/code&gt; succeeding means precisely this: it authenticated to the server — using your password, note — appended your public key to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;, and exited. Its exit code is a claim about the contents of a file, not about how future logins behave. The passwordless login that follows proves "some authentication method succeeded", nothing sharper. Key auth working and password auth being closed are two independent switches. Flipping the first tells you nothing about the second, and OpenSSH ships with the second on: on this laptop, &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; still has &lt;code&gt;#PasswordAuthentication yes&lt;/code&gt; commented out, which means the default, which means yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSH hides its refusals from the side you are watching
&lt;/h2&gt;

&lt;p&gt;Authentication is a negotiation. The server offers an ordered list of methods, the client walks down it, and when one method is refused the client moves quietly to the next. The refusal is recorded once, in the server's auth log, which is the one place nobody looks during setup.&lt;/p&gt;

&lt;p&gt;I have watched that negotiation go wrong on my own machine with no useful message at all. &lt;code&gt;ssh&lt;/code&gt; from this Kali laptop to that same Mac printed &lt;code&gt;Permission denied&lt;/code&gt; three times, then &lt;code&gt;Too many authentication failures&lt;/code&gt;. The real cause appeared nowhere in the output: KDE's password helper, &lt;code&gt;ksshaskpass&lt;/code&gt;, had intercepted the prompt, failed to render it, and handed ssh an empty password each time. On top of that, the client offered every key it had first, and each offer spent one of the server's authentication attempts before the password was ever tried. &lt;code&gt;SSH_ASKPASS_REQUIRE=never&lt;/code&gt; and &lt;code&gt;-o PubkeyAuthentication=no&lt;/code&gt; got through. The point is not the KDE bug. It is that from the client side, a broken method and a skipped method look the same.&lt;/p&gt;

&lt;p&gt;Keys get refused for reasons with no client-side symptom at all. The classic one is permissions: sshd's &lt;code&gt;StrictModes&lt;/code&gt; checks the remote &lt;code&gt;~/.ssh&lt;/code&gt;, &lt;code&gt;authorized_keys&lt;/code&gt; and your home directory, and if any of them is group-writable it ignores &lt;code&gt;authorized_keys&lt;/code&gt; entirely. Your login still works through password fallback, so everything looks fine until the day you disable passwords and lock yourself out.&lt;/p&gt;

&lt;p&gt;That leaves two invisible states that look identical from your terminal: key auth broken but masked by password fallback, and key auth fine with the password door wide open. In both, you are logged in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core, plus the one test that does not lie
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;whoami&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ed25519 &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/id_ed25519
ssh-copy-id &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/id_ed25519.pub user@server
ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;PasswordAuthentication&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no user@server   &lt;span class="c"&gt;# proves the KEY works — not "something worked"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-N ""&lt;/code&gt; gives the key an empty passphrase so generation runs without a prompt; leave it out of an automated run and the script sits waiting for input. The chmod lines are not ceremony: SSH refuses a private key other users can read, with a message people reliably misread as a passphrase problem.&lt;/p&gt;

&lt;p&gt;The last line is the one that matters. Forcing &lt;code&gt;PasswordAuthentication=no&lt;/code&gt; for a single connection removes the fallback, so success can only mean the key authenticated. If that command prompts or fails, your key is being refused — usually by server-side permissions — and you find out while the other way in still exists.&lt;/p&gt;

&lt;p&gt;Then the actual finish line, on the server: &lt;code&gt;PasswordAuthentication no&lt;/code&gt; in &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt;, reload sshd, and run the &lt;code&gt;PreferredAuthentications=none&lt;/code&gt; probe again. &lt;code&gt;password&lt;/code&gt; should be gone from the list. Do it with your current session held open and test from a second terminal, so a silently refused key costs you a fix and not a locked door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the setup is a script
&lt;/h2&gt;

&lt;p&gt;Because doing it from memory fails in small ways: the wrong key type one time, the wrong output path another, &lt;code&gt;-N&lt;/code&gt; forgotten so the run hangs. The full version on the page refuses to overwrite an existing key pair, sets directory and key permissions on every run, prints the public half ready to paste into a cloud control panel, and runs &lt;code&gt;ssh-copy-id&lt;/code&gt; for you when you give it a target, with a loop variant for pushing one key to several servers.&lt;/p&gt;

&lt;p&gt;Every public IPv4 address gets probed for SSH passwords around the clock. The green lights from key setup do not tell you whether that matters to your server. One refusal message does.&lt;/p&gt;

&lt;p&gt;Full script with the existing-key guard, permission handling and the multi-server loop: &lt;a href="https://bashsnippets.xyz/snippets/ssh-key-setup-script" rel="noopener noreferrer"&gt;https://bashsnippets.xyz/snippets/ssh-key-setup-script&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If permissions are the part that has bitten you, &lt;a href="https://bashsnippets.xyz/snippets/file-permissions-security" rel="noopener noreferrer"&gt;file permissions and security&lt;/a&gt; covers the modes SSH insists on, &lt;a href="https://bashsnippets.xyz/snippets/list-open-ports-linux" rel="noopener noreferrer"&gt;list open ports&lt;/a&gt; shows what else your box is answering on, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;p&gt;If your next move after fixing a key is generating one properly, the &lt;a href="https://bashsnippets.xyz/snippets/ssh-key-setup-script" rel="noopener noreferrer"&gt;SSH key setup script&lt;/a&gt; covers that end to end, and when you need a mode you do not have memorized, the &lt;a href="https://bashsnippets.xyz/tools/chmod-permissions-builder" rel="noopener noreferrer"&gt;Chmod Permissions Builder&lt;/a&gt; turns checkboxes into the octal value and the exact chmod command. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>I Deleted a 48M Log to Free Space. The Next Write Still Hit 'No space left on device'.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-deleted-a-48m-log-to-free-space-the-next-write-still-hit-no-space-left-on-device-a26</link>
      <guid>https://dev.to/bashsnippets/i-deleted-a-48m-log-to-free-space-the-next-write-still-hit-no-space-left-on-device-a26</guid>
      <description>&lt;p&gt;A 64M filesystem, a worker that appends to a log and keeps it open the way a daemon does, and the reflex everyone has when a disk fills up: delete the log. I built it on a tmpfs mounted on &lt;code&gt;/srv&lt;/code&gt; inside &lt;code&gt;unshare -rm&lt;/code&gt;, a private mount namespace with no sudo, where I show up as root. The worker is a bash process that writes 48M to &lt;code&gt;worker.log&lt;/code&gt; through file descriptor 3 and then turns into &lt;code&gt;sleep&lt;/code&gt;, still holding the descriptor:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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




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

&lt;p&gt;To hear about a filling disk before a write fails, the &lt;a href="https://bashsnippets.xyz/snippets/disk-space-warning" rel="noopener noreferrer"&gt;disk space warning&lt;/a&gt; script alerts at a threshold you pick, and &lt;a href="https://bashsnippets.xyz/snippets/find-large-files-linux" rel="noopener noreferrer"&gt;Find Large Files on Linux&lt;/a&gt; covers what is still on the disk with a name. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Tested a jq Alert Against a Down Status. It Printed 'no alert'.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-tested-a-jq-alert-against-a-down-status-it-printed-no-alert-mgk</link>
      <guid>https://dev.to/bashsnippets/i-tested-a-jq-alert-against-a-down-status-it-printed-no-alert-mgk</guid>
      <description>&lt;p&gt;The shape is everywhere in monitoring scripts: curl a status endpoint, pull one field out with jq, compare it, fire an alert on a match. I wrote the smallest honest version of it on my own machine, jq 1.8.1, against a response that said the service was down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"status":"down"}'&lt;/span&gt;
&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.status'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;                                                 &lt;span class="c"&gt;# ["down"]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"down"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo &lt;/span&gt;ALERT&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"no alert"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;   &lt;span class="c"&gt;# no alert&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/snippets/bash-parse-json-jq" rel="noopener noreferrer"&gt;parse JSON with jq&lt;/a&gt; snippet covers these patterns in script form with the hardening around them, &lt;a href="https://bashsnippets.xyz/snippets/bash-curl-api-requests" rel="noopener noreferrer"&gt;curl for API requests&lt;/a&gt; is the other half of the same pipeline, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

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

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

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

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

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

&lt;p&gt;grep's default engine is Basic Regular Expressions, and in BRE the pipe is not alternation. It is a literal character. &lt;code&gt;error|failed&lt;/code&gt; does not mean "error or failed"; it means the twelve-character string &lt;code&gt;error|failed&lt;/code&gt;, pipe included, which no logger has ever written. The same goes for &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt; and parentheses: in BRE they are ordinary characters unless you backslash-escape them. Alternation the way every other regex engine behaves needs &lt;code&gt;-E&lt;/code&gt;, Extended Regular Expressions — the mode &lt;code&gt;egrep&lt;/code&gt; has been shorthand for since long before most of us started typing it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# BRE (default): the pipe is a literal character — matches nothing, exits 1&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"error|failed"&lt;/span&gt; backup.log

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;The &lt;a href="https://bashsnippets.xyz/snippets/search-files-for-text-grep" rel="noopener noreferrer"&gt;Search Files for Text snippet&lt;/a&gt; covers the grep patterns worth keeping, &lt;a href="https://bashsnippets.xyz/snippets/delete-old-log-files" rel="noopener noreferrer"&gt;Delete Old Log Files&lt;/a&gt; handles the other half of log hygiene, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>webdev</category>
      <category>tools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Moved a Script to Another Directory on PATH. bash Said 'No such file or directory' and Exited 127.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-moved-a-script-to-another-directory-on-path-bash-said-no-such-file-or-directory-and-exited-127-36hj</link>
      <guid>https://dev.to/bashsnippets/i-moved-a-script-to-another-directory-on-path-bash-said-no-such-file-or-directory-and-exited-127-36hj</guid>
      <description>&lt;p&gt;One bash process, one small demo script called &lt;code&gt;report&lt;/code&gt;, and a &lt;code&gt;mv&lt;/code&gt; between two directories that are both on &lt;code&gt;PATH&lt;/code&gt;. I set it up in a scratch tmpfs mounted over &lt;code&gt;/opt&lt;/code&gt; inside &lt;code&gt;unshare -rm&lt;/code&gt;, with &lt;code&gt;PATH&lt;/code&gt; starting &lt;code&gt;/opt/jobs/bin:/opt/jobs/newbin&lt;/code&gt;, and ran it as a script. Inside the namespace I appear as root, which is why &lt;code&gt;ls&lt;/code&gt; says &lt;code&gt;root root&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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




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

&lt;p&gt;If 127 is all a log gives you, the &lt;a href="https://bashsnippets.xyz/tools/bash-exit-code-lookup" rel="noopener noreferrer"&gt;Bash Exit Code Lookup&lt;/a&gt; explains it next to 126, and the &lt;a href="https://bashsnippets.xyz/tools/path-debugger" rel="noopener noreferrer"&gt;Bash $PATH Debugger&lt;/a&gt; checks a pasted &lt;code&gt;PATH&lt;/code&gt; for missing directories and duplicates. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>I Pointed rsync --delete at a Nearly Empty Directory. It Deleted 3,800 Files and Exited 0.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/i-pointed-rsync-delete-at-a-nearly-empty-directory-it-deleted-3800-files-and-exited-0-4ic7</link>
      <guid>https://dev.to/bashsnippets/i-pointed-rsync-delete-at-a-nearly-empty-directory-it-deleted-3800-files-and-exited-0-4ic7</guid>
      <description>&lt;p&gt;The rsync accident that ends backups is not an error. It is rsync doing exactly what it was told, successfully, to the wrong directory. I wanted to see how quiet that is, so I rebuilt it in a scratch directory on my own machine with rsync 3.5.0.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;p&gt;For the script around it — cron scheduling, SSH keys, a hardened nightly job — the &lt;a href="https://bashsnippets.xyz/snippets/rsync-remote-backup" rel="noopener noreferrer"&gt;Rsync Remote Backup&lt;/a&gt; snippet pairs with this tool, &lt;a href="https://bashsnippets.xyz/snippets/automated-file-backup" rel="noopener noreferrer"&gt;Automated File Backup&lt;/a&gt; covers the local variant, and the rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>bash</category>
      <category>webdev</category>
      <category>tools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>My Machine Has No mail Command. That's Where Most Disk Alerts Die.</title>
      <dc:creator>Anguishe</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/bashsnippets/my-machine-has-no-mail-command-thats-where-most-disk-alerts-die-mj</link>
      <guid>https://dev.to/bashsnippets/my-machine-has-no-mail-command-thats-where-most-disk-alerts-die-mj</guid>
      <description>&lt;p&gt;I recently found that the disk check on my own Linux machine had been printing &lt;code&gt;WARNING: Disk at 88%&lt;/code&gt; into nothing: cron's journal said &lt;code&gt;No MTA installed, discarding output&lt;/code&gt; beside every run. So I checked what it would take for that warning to reach me by email. &lt;code&gt;mail&lt;/code&gt;: not installed. &lt;code&gt;mailx&lt;/code&gt;: not installed. &lt;code&gt;sendmail&lt;/code&gt;: not installed. &lt;code&gt;msmtp&lt;/code&gt;: not installed.&lt;/p&gt;

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

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

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

&lt;p&gt;The core is small enough to memorize:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="nv"&gt;USAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; / | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR==2 {print $5}'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'%'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 80 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'Disk on %s hit %s%% at %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | mail &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"[ALERT] disk space on &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; you@example.com
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;An email alert is the last guard an unattended job needs, not the first: the &lt;a href="https://bashsnippets.xyz/tools/cron-wrapper-generator" rel="noopener noreferrer"&gt;Cron Wrapper Generator&lt;/a&gt; wires alert-on-failure in alongside the lock, timeout and retry it generates, and &lt;a href="https://bashsnippets.xyz/guides/bash-scripts-that-survive-cron" rel="noopener noreferrer"&gt;Bash Scripts That Survive Cron&lt;/a&gt; is the full playbook for jobs nobody watches. The rest of the library is at &lt;a href="https://bashsnippets.xyz" rel="noopener noreferrer"&gt;https://bashsnippets.xyz&lt;/a&gt;&lt;/p&gt;

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