<?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: Schiff Heimlich</title>
    <description>The latest articles on DEV Community by Schiff Heimlich (@schiff_heimlich).</description>
    <link>https://dev.to/schiff_heimlich</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%2F3949704%2F89c08e96-274f-4f09-a299-8ebdabdc7096.jpg</url>
      <title>DEV Community: Schiff Heimlich</title>
      <link>https://dev.to/schiff_heimlich</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/schiff_heimlich"/>
    <language>en</language>
    <item>
      <title>Debugging DNS Propagation When Records Just Don't Show Up</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Sat, 17 Oct 2026 17:01:41 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/debugging-dns-propagation-when-records-just-dont-show-up-lfg</link>
      <guid>https://dev.to/schiff_heimlich/debugging-dns-propagation-when-records-just-dont-show-up-lfg</guid>
      <description>&lt;p&gt;We've all been there. You update a DNS record, wait for it to propagate, and... nothing. The old record still shows up. You check the TTL, you check the authoritative server, everything looks right on paper, but your application still can't resolve the new value.&lt;/p&gt;

&lt;p&gt;Here's the thing: DNS propagation isn't just about TTL expiry. There's a whole async world of background tasks, caching layers, and stuck processes that can delay or completely block propagation, and standard tools won't show you what's actually happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problem
&lt;/h2&gt;

&lt;p&gt;Most DNS debugging starts with &lt;code&gt;dig&lt;/code&gt; or &lt;code&gt;nslookup&lt;/code&gt;, checking TTLs, querying different resolvers. That's fine for basic issues. But when you're dealing with propagation failures in production—especially in complex infrastructure with multiple DNS layers—those tools hit a wall.&lt;/p&gt;

&lt;p&gt;The issue isn't usually the DNS servers themselves. It's the stuff between them. Background tasks that haven't finished. Stale caches in unexpected places. Async propagation jobs that got stuck or failed silently.&lt;/p&gt;

&lt;p&gt;I spent weeks tracking down a propagation issue that turned out to be a background sync task between our primary and secondary DNS infrastructure. The task was running, but not actually completing the updates. Standard DNS queries showed everything was "correct" at each layer, but the actual propagation wasn't happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Internal Debug Tools
&lt;/h2&gt;

&lt;p&gt;The solution wasn't to add more monitoring or alerts. It was to build a small internal debug tool that could trace the actual propagation path, step by step.&lt;/p&gt;

&lt;p&gt;Here's what that looked like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Trace propagation state at each layer&lt;/strong&gt; - Not just querying DNS, but checking the actual state of background sync jobs, cache invalidation status, and async task queues&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify stuck tasks&lt;/strong&gt; - Background jobs that were running but not making progress, or failing silently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare expected vs actual state&lt;/strong&gt; - What the DNS config said vs what was actually deployed at each point in the chain&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tool was simple—just a few shell scripts that pulled state from our DNS management system, our background job queue, and our caching layer. But it gave us visibility into where propagation was actually breaking down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Look For
&lt;/h2&gt;

&lt;p&gt;When you're debugging DNS propagation that's not behaving:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check your background sync jobs&lt;/strong&gt; - Are they actually completing, or just running without doing work?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look for async delays&lt;/strong&gt; - Some systems queue DNS updates and process them asynchronously. If that queue is backed up or stuck, propagation stalls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify cache invalidation&lt;/strong&gt; - It's not enough to update the record. The cache needs to know to drop the old value. Sometimes invalidation fails silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the actual deployment state&lt;/strong&gt; - What's in your DNS config vs what's actually deployed at each nameserver can be different if deployment is multi-stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keep It Simple
&lt;/h2&gt;

&lt;p&gt;You don't need a fancy observability platform for this. A few scripts that can query your DNS management system's internal state, check your job queues, and compare config vs deployed state will get you 90% of the way there.&lt;/p&gt;

&lt;p&gt;The key is understanding that DNS propagation is not just a TTL thing. It's a distributed system with async tasks, caches, and multiple layers. When propagation fails, the problem is usually in those layers, not in the DNS protocol itself.&lt;/p&gt;

&lt;p&gt;Build a tool that shows you what's actually happening in those layers. You'll save yourself hours of staring at &lt;code&gt;dig&lt;/code&gt; output wondering why the record "should" have propagated but didn't.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>sysadmin</category>
      <category>dns</category>
      <category>debugging</category>
    </item>
    <item>
      <title>64-Day Certificates Are Coming: Two Small Tweaks to Your Renewal Logic</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Thu, 15 Oct 2026 17:04:52 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/64-day-certificates-are-coming-two-small-tweaks-to-your-renewal-logic-4nfm</link>
      <guid>https://dev.to/schiff_heimlich/64-day-certificates-are-coming-two-small-tweaks-to-your-renewal-logic-4nfm</guid>
      <description>&lt;p&gt;Let's Encrypt is dropping certificate validity from 90 days to 64 days starting February 2027. If you're like me, your first thought was probably "64 days is still 64 days, what's the big deal?"&lt;/p&gt;

&lt;p&gt;The big deal is that a lot of renewal scripts make assumptions about timing that break when you compress the cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Math Problem
&lt;/h2&gt;

&lt;p&gt;With 90-day certs, certbot's default 30-day renewal window meant renewals happened around day 60. That gave you a comfortable 30-day buffer. With 64-day certs, if you keep that same 30-day window, you're renewing at day 34. That's only a 4-day overlap if something goes wrong.&lt;/p&gt;

&lt;p&gt;Most people will adjust the window to 20 days, which means renewals at day 44. Better, but still tighter than before.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Line That Breaks
&lt;/h2&gt;

&lt;p&gt;Here's a renewal check I've seen in a few places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;openssl x509 &lt;span class="nt"&gt;-enddate&lt;/span&gt; &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; cert.pem | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 30 &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;certbot renew
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works fine when you're checking 30 days before expiry on a 90-day cert. But with 64-day certs, you might want to check 20 days before expiry instead. The number itself isn't magic—it's just a threshold. The real question is: what's your actual renewal schedule, and does your check match it?&lt;/p&gt;

&lt;h2&gt;
  
  
  A Cleaner Approach
&lt;/h2&gt;

&lt;p&gt;Instead of hardcoding days, calculate the renewal window as a percentage of the cert lifetime:&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;# Get cert lifetime in seconds&lt;/span&gt;
&lt;span class="nv"&gt;LIFETIME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl x509 &lt;span class="nt"&gt;-enddate&lt;/span&gt; &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; cert.pem | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl x509 &lt;span class="nt"&gt;-startdate&lt;/span&gt; &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; cert.pem | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="c"&gt;# Renew at 70% of lifetime (works for any cert duration)&lt;/span&gt;
&lt;span class="nv"&gt;RENEWAL_THRESHOLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;LIFETIME &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl x509 &lt;span class="nt"&gt;-enddate&lt;/span&gt; &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; cert.pem | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="nv"&gt;$RENEWAL_THRESHOLD&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;certbot renew &lt;span class="nt"&gt;--quiet&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scales whether you're on 90-day, 64-day, or some future 45-day cert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Monitoring?
&lt;/h2&gt;

&lt;p&gt;Most monitoring tools check "days until expiry" against a static threshold. If you're alerting at 30 days, that worked fine for 90-day certs (you'd get alerted once per cycle). With 64-day certs and a 20-day renewal window, you might want to alert at 15 days instead—just enough time to catch a failed renewal before the cert actually expires.&lt;/p&gt;

&lt;p&gt;Check your alerting config. It's probably hardcoded somewhere.&lt;/p&gt;




&lt;p&gt;The change itself isn't dramatic. It's just a reminder that "90 days" was never the point—the point was having enough buffer to handle failures. When the cycle compresses, the buffer compresses too. Adjust accordingly.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>certificates</category>
      <category>letsencrypt</category>
      <category>automation</category>
    </item>
    <item>
      <title>The nginx proxy_pass Trailing Slash Detail That Caught Me</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:02:09 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/the-nginx-proxypass-trailing-slash-detail-that-caught-me-5d29</link>
      <guid>https://dev.to/schiff_heimlich/the-nginx-proxypass-trailing-slash-detail-that-caught-me-5d29</guid>
      <description>&lt;p&gt;Here's something that'll make you waste an afternoon if you're not careful.&lt;/p&gt;

&lt;p&gt;You're setting up nginx as a reverse proxy. You have a location block, and inside it you use &lt;code&gt;proxy_pass&lt;/code&gt; to forward to your backend. Simple enough.&lt;/p&gt;

&lt;p&gt;But there's this one detail about trailing slashes that changes everything, and it's not obvious until you've been burned by it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Behaviors
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;With a trailing slash:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request to &lt;code&gt;/api/users&lt;/code&gt; becomes &lt;code&gt;/users&lt;/code&gt; at the backend. The &lt;code&gt;/api/&lt;/code&gt; prefix gets stripped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without a trailing slash:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request to &lt;code&gt;/api/users&lt;/code&gt; stays &lt;code&gt;/api/users&lt;/code&gt; at the backend. The full path is preserved.&lt;/p&gt;

&lt;p&gt;That's it. One slash changes whether your backend sees &lt;code&gt;/users&lt;/code&gt; or &lt;code&gt;/api/users&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simple Mental Model
&lt;/h2&gt;

&lt;p&gt;Think of it this way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slash present&lt;/strong&gt; = "replace the matched location with this"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No slash&lt;/strong&gt; = "prepend this to the existing path"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the slash, nginx treats it as "strip the prefix and replace with what's after the slash." Without it, it just passes through unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  When This Matters
&lt;/h2&gt;

&lt;p&gt;Most of the time you want the slash-stripping behavior. Your backend doesn't need to know it's mounted at &lt;code&gt;/api/&lt;/code&gt; — it just wants &lt;code&gt;/users&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But sometimes your backend expects the full path. Maybe it's serving multiple apps and uses the prefix to route internally. Then you leave the slash off.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use &lt;code&gt;rewrite&lt;/code&gt; Instead
&lt;/h2&gt;

&lt;p&gt;If you need something more complex — like renaming paths or doing conditional routing — don't try to hack it with &lt;code&gt;proxy_pass&lt;/code&gt; slashes. Just use &lt;code&gt;rewrite&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/api/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/v2/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/api/users&lt;/code&gt; becomes &lt;code&gt;/v2/users&lt;/code&gt; at the backend. Clear, explicit, no magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;When you're setting up a reverse proxy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check if your backend expects the prefix or not&lt;/li&gt;
&lt;li&gt;Add or remove the trailing slash accordingly&lt;/li&gt;
&lt;li&gt;If you need anything fancier, use &lt;code&gt;rewrite&lt;/code&gt; instead of trying to be clever&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's one character. But it'll cost you two hours of debugging if you get it wrong.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Two Small restic Details That Actually Matter for B2 Backups</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Mon, 12 Oct 2026 17:02:23 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/two-small-restic-details-that-actually-matter-for-b2-backups-m59</link>
      <guid>https://dev.to/schiff_heimlich/two-small-restic-details-that-actually-matter-for-b2-backups-m59</guid>
      <description>&lt;p&gt;You're setting up encrypted offsite backups with restic and Backblaze B2. The basic setup works fine. But there are two small details worth getting right from the start, because fixing them later is annoying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The B2 Lifecycle Setting You Probably Missed
&lt;/h2&gt;

&lt;p&gt;When you create your B2 bucket, there's a "Lifecycle Settings" option. Set it to "Keep only the last version of the file."&lt;/p&gt;

&lt;p&gt;Why this matters: restic deduplicates and prunes old snapshots. When it prunes, it marks chunks as deleted. But if B2 is configured to keep all versions, those "deleted" chunks stick around as old versions. You keep paying for them.&lt;/p&gt;

&lt;p&gt;You won't notice this on day one. You'll notice it six months later when your B2 bill is higher than expected and you're wondering why.&lt;/p&gt;

&lt;p&gt;This is a one-time setting when you create the bucket. If you missed it, you can update it in the bucket settings, but you'll need to run &lt;code&gt;restic prune&lt;/code&gt; again to actually clean up the old versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The systemd Timer Flags Worth Knowing
&lt;/h2&gt;

&lt;p&gt;If you're automating restic with systemd timers (which you probably are), two flags in the &lt;code&gt;[Timer]&lt;/code&gt; section are worth understanding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 03:00:00&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1800&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RandomizedDelaySec=1800&lt;/code&gt; spreads out the start time by up to 30 minutes. This matters if you have multiple machines backing up to the same B2 bucket. Without it, they all hit the API at exactly 3:00 AM and you get rate-limited or just hammer the connection.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Persistent=true&lt;/code&gt; means if the system was off at 3:00 AM (laptop was asleep, server was down for maintenance, whatever), the backup runs as soon as it boots. Without this, you just skip that day's backup entirely.&lt;/p&gt;

&lt;p&gt;Both of these are small details. Both of them prevent problems you'd rather not debug at 2 AM when your backup fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Other Thing
&lt;/h2&gt;

&lt;p&gt;Store your restic encryption password somewhere safe. Print it out if you have to. If you lose it, the encrypted blobs in B2 are just noise. There's no recovery. I keep mine in my password manager and also printed in a fireproof box, because I'm paranoid about the one thing I can't recover from.&lt;/p&gt;

&lt;p&gt;That's it. Small details, but they're the difference between "backups just work" and "backups work until they don't."&lt;/p&gt;

</description>
      <category>devops</category>
      <category>sysadmin</category>
      <category>backup</category>
      <category>linux</category>
    </item>
    <item>
      <title>systemd Service Ordering: Why Your Database Starts Before the Network Is Actually Up</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Mon, 12 Oct 2026 11:20:06 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/systemd-service-ordering-why-your-database-starts-before-the-network-is-actually-up-13b2</link>
      <guid>https://dev.to/schiff_heimlich/systemd-service-ordering-why-your-database-starts-before-the-network-is-actually-up-13b2</guid>
      <description>&lt;p&gt;Here's something that bit me recently.&lt;/p&gt;

&lt;p&gt;You write a service unit with &lt;code&gt;After=network.target&lt;/code&gt;, thinking your database will wait for the network. It doesn't. &lt;code&gt;network.target&lt;/code&gt; only means the network &lt;em&gt;stack&lt;/em&gt; is up — not that an interface has an IP, not that DNS works, not that you can reach anything.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Database&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks right. But &lt;code&gt;network.target&lt;/code&gt; is passive — it's pulled in by things that &lt;em&gt;provide&lt;/em&gt; network, not things that &lt;em&gt;require&lt;/em&gt; a working connection. Your service starts, tries to bind to 0.0.0.0:5432, and if the interface hasn't come up yet (DHCP, WiFi, VPN, whatever), it binds to nothing useful or fails silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;My Database&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And enable the waiter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;systemd-networkd-wait-online.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;network-online.target&lt;/code&gt; is the one that actually waits for &lt;code&gt;systemd-networkd&lt;/code&gt; (or NetworkManager, or whatever) to report "yeah, we're online." &lt;code&gt;Wants=&lt;/code&gt; pulls it in so the target is active. &lt;code&gt;After=&lt;/code&gt; orders after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha
&lt;/h2&gt;

&lt;p&gt;If you're not using &lt;code&gt;systemd-networkd&lt;/code&gt; — say you're on NetworkManager — you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;NetworkManager-wait-online.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea. Different service name.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; ActiveState network-online.target
&lt;span class="c"&gt;# Should say 'active'&lt;/span&gt;

systemd-analyze plot &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; boot.svg
&lt;span class="c"&gt;# Open it, find your service, see what it actually waited for&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plot doesn't lie. Your service bar should start &lt;em&gt;after&lt;/em&gt; the network-online target bar ends.&lt;/p&gt;




&lt;p&gt;Not a novel discovery. Just one of those things that's documented but easy to miss when you're in a hurry.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>That Time Someone Exploited a Git Hook to Root Your Homelab</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Sat, 05 Sep 2026 17:02:47 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/that-time-someone-exploited-a-git-hook-to-root-your-homelab-h13</link>
      <guid>https://dev.to/schiff_heimlich/that-time-someone-exploited-a-git-hook-to-root-your-homelab-h13</guid>
      <description>&lt;h1&gt;
  
  
  That Time Someone Exploited a Git Hook to Root Your Homelab
&lt;/h1&gt;

&lt;p&gt;You know how it is—you spin up a self-hosted Git instance, maybe Forgejo or Gitea, and you're feeling good about having your own GitHub alternative. Then you forget about it for a while. That's usually when something bad happens.&lt;/p&gt;

&lt;p&gt;Someone just posted a writeup about waking up to Pushover alerts because their i5-10600K was pinned at 50% CPU for 24 hours straight. The investigation turned into a reverse-engineering rabbit hole that's worth reading if you run any self-hosted Git services.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;The short version: they were running Forgejo v13, which hit end-of-life in January 2026. They had open signup enabled (mistake #1). An attacker registered, poked around, and found CVE-2026-60004—a vulnerability in the Gitea diffpatch endpoint that lets you write arbitrary files to the repository.&lt;/p&gt;

&lt;p&gt;The attacker used it to drop a malicious &lt;code&gt;post-index-change&lt;/code&gt; Git hook. If you're not familiar with Git hooks, they're scripts that run at various points in the Git workflow—pre-commit, post-merge, and so on. They're incredibly useful, and also incredibly dangerous if you don't control who can write them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payload was thorough
&lt;/h2&gt;

&lt;p&gt;The hook didn't just do one thing. It:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Killed any processes with "guard" in the name (probably monitoring or security tools)&lt;/li&gt;
&lt;li&gt;Disabled systemd units for competing CPU-intensive workloads (to free up resources for the attacker's binaries)&lt;/li&gt;
&lt;li&gt;Downloaded architecture-specific binaries using six different fallback methods (curl, wget, Python, etc.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six fallback download methods. That's not a script kiddie—someone planned this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The painful part
&lt;/h2&gt;

&lt;p&gt;The author admits they should have been reading the release notes. Forgejo v13 was EOL since January 2026, and CVE-2026-60004 was patched in newer versions. But let's be honest—how many of us actually read release notes for every self-hosted service we run? We update when something breaks, not when there's a security fix.&lt;/p&gt;

&lt;p&gt;The other issue: open signup on a self-hosted Git instance. If you're running this for a team, fine. If you're running it for yourself, why is anyone else allowed to register? There's usually a config flag to disable public registration. Use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check
&lt;/h2&gt;

&lt;p&gt;If you're running Forgejo, Gitea, or any self-hosted Git platform:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What version are you on?&lt;/strong&gt; Check if it's EOL. If it is, update now. Not "soon," not "this weekend," now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is signup open?&lt;/strong&gt; If you don't need it, disable it. There's no reason to let random people create accounts on your personal Git server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can users write Git hooks?&lt;/strong&gt; This is the critical one. Git hooks are powerful—they run with the same privileges as the Git server process. If an attacker can write a hook, they own your system. Lock this down in the config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are you monitoring CPU/disk/network?&lt;/strong&gt; The author only caught this because of sustained high CPU. Basic monitoring would have flagged this earlier.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;Self-hosted services are great until they're not. The moment you deploy something, you're responsible for keeping it updated, securing it, and monitoring it. There's no team of engineers at GitHub doing this for you.&lt;/p&gt;

&lt;p&gt;If you can't commit to that, use the hosted version. If you can, at least read the release notes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Schiff Heimlich | Still updating things I forgot about&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>sysadmin</category>
      <category>git</category>
    </item>
    <item>
      <title>Who Watches Your Watchdog? (And Why It Probably Isn't You)</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Thu, 03 Sep 2026 17:01:36 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/who-watches-your-watchdog-and-why-it-probably-isnt-you-2koh</link>
      <guid>https://dev.to/schiff_heimlich/who-watches-your-watchdog-and-why-it-probably-isnt-you-2koh</guid>
      <description>&lt;h1&gt;
  
  
  Who Watches Your Watchdog? (And Why It Probably Isn't You)
&lt;/h1&gt;

&lt;p&gt;Been thinking about this after reading about someone who built a cron-job monitoring service and had it silently stop running. The watchdog stopped watching. No alerts, no notifications. Just quiet failure.&lt;/p&gt;

&lt;p&gt;It's one of those things that sounds obvious in hindsight but catches a lot of people.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;You've got some jobs running on a schedule. Maybe they're important, maybe they're not, but you want to know when they fail. So you build or deploy something that checks on them - a watchdog service that sends you an alert if a job misses its window.&lt;/p&gt;

&lt;p&gt;This works fine. Until the watchdog itself stops working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong
&lt;/h2&gt;

&lt;p&gt;The person running PulseWatch (a Python-based cron watchdog SaaS) had it running on GitHub Actions as a scheduler. The scheduler hiccuped - not the jobs being monitored, but the thing checking the jobs. The watchdog just... stopped.&lt;/p&gt;

&lt;p&gt;Here's the thing: if your watchdog dies, you don't get notified. That's the whole point of having a watchdog, and it's also the failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The solution was to chain the watchdog to an external probe - specifically, a Healthchecks.io endpoint. The watchdog pings Healthchecks.io on a schedule, and if Healthchecks.io doesn't hear from it, &lt;em&gt;that&lt;/em&gt; triggers an alert.&lt;/p&gt;

&lt;p&gt;Now you have two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your watchdog monitors your jobs&lt;/li&gt;
&lt;li&gt;Healthchecks.io monitors your watchdog&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure domains are separated. If your watchdog dies, an external service notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grace period trap
&lt;/h2&gt;

&lt;p&gt;There's another lesson here about how grace periods work. The author had a "15-minute cron" but assumed that meant a 15-minute grace period. It doesn't.&lt;/p&gt;

&lt;p&gt;A cron expression of &lt;code&gt;*/15 * * * *&lt;/code&gt; means "run every 15 minutes" but doesn't guarantee exactly every 15 minutes. GitHub Actions might run it at :00, :15, :30, :45 - or it might be :02, :17, :32, :47. The scheduler has its own timing.&lt;/p&gt;

&lt;p&gt;If your grace period is tied to your cron interval, you're going to get false alerts when the scheduler drifts. The fix is to make the grace period longer than the cron interval, with enough buffer for scheduling variance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check
&lt;/h2&gt;

&lt;p&gt;If you're running any kind of monitoring or alerting system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;How do you know it's still running?&lt;/strong&gt; Is there an external check on the check?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's your grace period?&lt;/strong&gt; Is it longer than your check interval plus scheduling variance?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where does the alert go?&lt;/strong&gt; If your primary alerting channel is down, is there a fallback?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The boring truth is that monitoring systems fail too. The question is whether you'll notice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Original research from Saijo. Source: &lt;a href="https://dev.to/chriscompiles/who-watches-the-watchdog-the-boring-work-behind-a-monitoring-saas-7n8"&gt;https://dev.to/chriscompiles/who-watches-the-watchdog-the-boring-work-behind-a-monitoring-saas-7n8&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Why Your Alpine Containers Might Be Hiding a Security Blind Spot</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Sat, 29 Aug 2026 17:12:18 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/why-your-alpine-containers-might-be-hiding-a-security-blind-spot-3gbk</link>
      <guid>https://dev.to/schiff_heimlich/why-your-alpine-containers-might-be-hiding-a-security-blind-spot-3gbk</guid>
      <description>&lt;p&gt;Hey,&lt;/p&gt;

&lt;p&gt;Been looking at container base images lately and noticed something worth sharing about Alpine that might not be obvious.&lt;/p&gt;

&lt;p&gt;Most of us reach for Alpine when we want small, efficient container images. It's become pretty standard for a lot of workloads. But there's a detail about its base that's easy to overlook: BusyBox.&lt;/p&gt;

&lt;p&gt;BusyBox bundles a bunch of Unix utilities into a single executable. It's great for embedded systems, but it wasn't really built with cloud-native container security in mind. The issue isn't that BusyBox is bad—it's that when a vulnerability shows up in any part of it, you've got exposure across your entire userspace since it provides so many core utilities.&lt;/p&gt;

&lt;p&gt;What's tricky is that this isn't something your usual container scanners will flag prominently. They'll tell you about CVEs in specific packages, but the BusyBox situation is a bit different—it's more about the architecture of the base image itself.&lt;/p&gt;

&lt;p&gt;Some teams are starting to address this by looking at base images that either minimize or eliminate BusyBox from the production image build process. The idea is to move some of that security consideration earlier during the image build phase rather than relying solely on runtime scanning.&lt;/p&gt;

&lt;p&gt;If you're using Alpine (or similar images), it might be worth checking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What's actually in your base image beyond your application dependencies&lt;/li&gt;
&lt;li&gt;Whether your scanning tools are giving you visibility into the userspace components&lt;/li&gt;
&lt;li&gt;If there's a build step where you could simplify or replace parts of the base image for your specific use case&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not about abandoning Alpine—it's about understanding what's in it and making sure that fits your security model.&lt;/p&gt;

&lt;p&gt;Let me know what you've seen in your own setups.&lt;/p&gt;

&lt;p&gt;--Schiff&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>docker</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>When 'No space left on device' has nothing to do with disk space</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Fri, 28 Aug 2026 17:10:40 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/when-no-space-left-on-device-has-nothing-to-do-with-disk-space-10jj</link>
      <guid>https://dev.to/schiff_heimlich/when-no-space-left-on-device-has-nothing-to-do-with-disk-space-10jj</guid>
      <description>&lt;p&gt;You're running a log shipper or a config reloader and suddenly it starts failing with &lt;code&gt;ENOSPC&lt;/code&gt; — "No space left on device." You check &lt;code&gt;df -h&lt;/code&gt;, and the disk is fine. Plenty of inodes, plenty of blocks. So why is the kernel refusing to do work?&lt;/p&gt;

&lt;p&gt;Chances are it's not disk space at all. It's the inotify watch limit.&lt;/p&gt;

&lt;p&gt;inotify is the kernel's file change notification mechanism. Every directory you watch with tools like &lt;code&gt;inotifywait&lt;/code&gt;, a config reloader, or a log shipper consumes a "watch." Those watches come from a finite pool, and when you exhaust it, the kernel returns &lt;code&gt;ENOSPC&lt;/code&gt; even though the filesystem is nowhere near full. The error message is misleading, and it's easy to burn an hour chasing disk space that was never the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the limits first
&lt;/h2&gt;

&lt;p&gt;Two sysctls control this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Max number of watches (default is often 8192 or 65536)&lt;/span&gt;
sysctl fs.inotify.max_user_watches

&lt;span class="c"&gt;# Max number of queued events per instance&lt;/span&gt;
sysctl fs.inotify.max_user_instances
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that bites most people is &lt;code&gt;max_user_watches&lt;/code&gt;. If you're running a bunch of containers or a monitoring agent that watches a large directory tree, 8192 goes fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm it's actually inotify
&lt;/h2&gt;

&lt;p&gt;If you're getting &lt;code&gt;ENOSPC&lt;/code&gt; and the disk is fine, check the kernel logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dmesg | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; inotify
journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; inotify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll often see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inotify watch limit reached
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's your smoking gun. It's not disk space, it's the watch table.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many watches do you actually need?
&lt;/h2&gt;

&lt;p&gt;Rough rule of thumb: one watch per directory you're monitoring. If you're watching a tree with 50,000 directories, you need at least 50,000 watches. You can get a rough count of what a tool is using with &lt;code&gt;strace&lt;/code&gt;, but for planning purposes, size it against the directory count of whatever you're shipping logs from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it persistently
&lt;/h2&gt;

&lt;p&gt;The one-liner that works until reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; fs.inotify.max_user_watches&lt;span class="o"&gt;=&lt;/span&gt;524288
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if you want it to survive a reboot, put it in a sysctl drop-in file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"fs.inotify.max_user_watches=524288"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/sysctl.d/90-inotify.conf
sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sysctl --system&lt;/code&gt; applies everything in &lt;code&gt;/etc/sysctl.d/&lt;/code&gt;, so it takes effect immediately and sticks across reboots. On systemd systems this is the clean way to do it — don't edit &lt;code&gt;/etc/sysctl.conf&lt;/code&gt; directly when a drop-in file does the job and keeps your change obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people forget
&lt;/h2&gt;

&lt;p&gt;Raising the watch limit is the easy half. The other thing to check is &lt;code&gt;max_user_instances&lt;/code&gt; — the number of separate inotify instances a single user can create. If you're spawning many short-lived watchers (some tools do this per-connection), you can hit that limit too. Bump it in the same drop-in file if you need to.&lt;/p&gt;

&lt;p&gt;Also remember: watches are a per-user resource, and the limit applies to &lt;em&gt;your&lt;/em&gt; user, not the whole box. If you run monitoring as root, root has its own budget. Container runtimes often share this, so a busy host can blow through it faster than you'd expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;When a monitoring tool fails with "No space left on device" and the disk is fine, don't fight the filesystem. Check &lt;code&gt;fs.inotify.max_user_watches&lt;/code&gt;, confirm with &lt;code&gt;dmesg&lt;/code&gt;, and raise it via a sysctl drop-in file. It's a ten-minute fix that looks like a disk emergency until you know what you're looking at.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>devops</category>
      <category>linux</category>
    </item>
    <item>
      <title>Nginx proxy_pass: The trailing slash that changes everything</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Thu, 27 Aug 2026 17:08:45 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/nginx-proxypass-the-trailing-slash-that-changes-everything-47b3</link>
      <guid>https://dev.to/schiff_heimlich/nginx-proxypass-the-trailing-slash-that-changes-everything-47b3</guid>
      <description>&lt;p&gt;You know how sometimes you copy an nginx config snippet and it just... doesn't work? You stare at it, reload, check the logs, and nothing seems wrong. Then you spot it: a missing or extra slash at the end of the proxy_pass line. It’s one of those tiny details that silently breaks your setup.&lt;/p&gt;

&lt;p&gt;Let’s look at two examples.&lt;/p&gt;

&lt;p&gt;First, with a trailing slash:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;location /app/ {
    proxy_pass http://backend/;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Here, nginx replaces the matched part (/app/) with the URI in proxy_pass (&lt;a href="http://backend/" rel="noopener noreferrer"&gt;http://backend/&lt;/a&gt;). So a request to /app/index.html goes to &lt;a href="http://backend/index.html" rel="noopener noreferrer"&gt;http://backend/index.html&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Now, without the trailing slash:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;location /app/ {
    proxy_pass http://backend;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;In this case, nginx passes the entire request URI unchanged. So /app/index.html becomes &lt;a href="http://backend/app/index.html" rel="noopener noreferrer"&gt;http://backend/app/index.html&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The difference is subtle but important. If your backend expects requests at the root (like /index.html), the first form is what you want. If it expects the path to be preserved (like /app/index.html), the second form works.&lt;/p&gt;

&lt;p&gt;I’ve seen this trip up people when they move from a location block that matches a prefix to one that’s more specific. The muscle memory of copying the proxy_pass line doesn’t account for the slash.&lt;/p&gt;

&lt;p&gt;It’s not a bug. It’s documented behavior. But it’s easy to overlook when you’re in a hurry. Next time your proxied requests are going to the wrong place, take ten seconds to check that slash.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>devops</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>That Time Your Deploy Failed Because SSH Thought You Were an Attacker</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Wed, 26 Aug 2026 17:04:51 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/that-time-your-deploy-failed-because-ssh-thought-you-were-an-attacker-4gj9</link>
      <guid>https://dev.to/schiff_heimlich/that-time-your-deploy-failed-because-ssh-thought-you-were-an-attacker-4gj9</guid>
      <description>&lt;h1&gt;
  
  
  That Time Your Deploy Failed Because SSH Thought You Were an Attacker
&lt;/h1&gt;

&lt;p&gt;You have got 30 Ansible tasks firing at once. Or maybe you are using Capistrano, or parallel-scp to push code to 30 servers at the same time. Mid-deploy, everything starts timing out. Connection refused. You blame the network. You blame AWS. You restart the deploy.&lt;/p&gt;

&lt;p&gt;It is your sshd_config.&lt;/p&gt;

&lt;p&gt;The default &lt;code&gt;MaxStartups&lt;/code&gt; setting is &lt;code&gt;10:30:60&lt;/code&gt;. Let me break that down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MaxStartups Actually Means
&lt;/h2&gt;

&lt;p&gt;The syntax is &lt;code&gt;start:rate:full&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;10&lt;/code&gt; = allow 10 unauthenticated connections before starting to drop&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;30&lt;/code&gt; = drop 30% of new connections once you hit 10&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;60&lt;/code&gt; = refuse all new connections once you hit 60&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So with 30 parallel SSH connections hitting the same sshd at once, you are going to start getting refused. Not throttled—refused. "Connection refused" errors that make you think the service is down when really sshd just does not like your burst.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Bites Deploys Specifically
&lt;/h2&gt;

&lt;p&gt;Parallel deployment tools are built for speed. They open connections to multiple hosts concurrently. If those connections all tunnel through a jump host or land on the same deployment box, you have suddenly got 30 simultaneous auth attempts from one IP.&lt;/p&gt;

&lt;p&gt;Your laptop doing 30 parallel SSH connections to prod-server-01? That is 30 connections from your IP to one port. Sshd thinks you are running a dictionary attack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;/etc/ssh/sshd_config&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;MaxStartups 30:30:100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if you know your deployment patterns, just bump it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MaxStartups 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload sshd: &lt;code&gt;sudo systemctl reload sshd&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If You are Using a Jump Host
&lt;/h2&gt;

&lt;p&gt;The jump host takes the hit. All your parallel connections pile up there. Make sure your jump host sshd_config can handle your parallelism, or use connection multiplexing to reuse authenticated connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Debugging Clue
&lt;/h2&gt;

&lt;p&gt;Next time you see mysterious connection refused errors only during deploys, check if it correlates with the number of parallel tasks. If your network team says "nothing wrong," check your sshd_config. Probably the sshd_config.&lt;/p&gt;




&lt;p&gt;The default MaxStartups exists for good reason—it is a basic throttle against SSH brute force. But it was not designed for automated parallel workflows. Know your tool defaults before you spend an hour debugging the network.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>linux</category>
    </item>
    <item>
      <title>That Time Client Retries Turned a Recovery Into a 7-Hour Outage</title>
      <dc:creator>Schiff Heimlich</dc:creator>
      <pubDate>Tue, 25 Aug 2026 17:06:20 +0000</pubDate>
      <link>https://dev.to/schiff_heimlich/that-time-client-retries-turned-a-recovery-into-a-7-hour-outage-51e2</link>
      <guid>https://dev.to/schiff_heimlich/that-time-client-retries-turned-a-recovery-into-a-7-hour-outage-51e2</guid>
      <description>&lt;p&gt;GitHub had an interesting incident last August. A component in Central US failed under load, and when it started recovering, the recovery took much longer than it should have. The culprit: clients hammering the recovering system with retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;During the outage, GitHub's auth system was down. When it came back up, every client that had been patiently waiting started hammering it with retry requests simultaneously. The auth system was trying to recover, but now it was drowning in retry traffic from clients that had given up waiting.&lt;/p&gt;

&lt;p&gt;This is the retry-loop trap. You've probably seen it in various forms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A health check endpoint that returns 503, so clients retry every second&lt;/li&gt;
&lt;li&gt;A connection pool that overflows, returns an error, clients reconnect immediately&lt;/li&gt;
&lt;li&gt;A CDN origin that's down, all edge nodes retry at the same rate&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;The typical flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;System goes down or slow&lt;/li&gt;
&lt;li&gt;Clients start retrying with some interval&lt;/li&gt;
&lt;li&gt;System partially recovers&lt;/li&gt;
&lt;li&gt;Retries flood the recovering system&lt;/li&gt;
&lt;li&gt;System goes down again or stays slow&lt;/li&gt;
&lt;li&gt;Repeat until someone intervenes&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What actually helps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Exponential backoff with jitter&lt;/strong&gt; is the standard answer. Instead of retrying every N seconds, you retry at increasingly longer intervals with some randomness. Like: 1s, 2s, 4s, 8s, 16s... with +/- 20% jitter. This spreads out the retry load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Circuit breakers&lt;/strong&gt; stop forwarding requests to a failing service entirely for a cooldown period. The client knows the circuit is "open" and fails fast instead of retrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting on the server side&lt;/strong&gt; helps, but during recovery you often want to gradually increase capacity, not hard-cutoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client-side rate limiting&lt;/strong&gt; is underused. Most HTTP clients will retry indefinitely by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  The GitHub specific angle
&lt;/h2&gt;

&lt;p&gt;GitHub's postmortem mentioned record traffic that day - 115M Actions runs, 2.9B monthly commits. That's a lot of automated systems hammering retry loops. The lesson isn't that GitHub's infra was bad; it's that the combination of a brief outage + automated clients with naive retry logic creates a thundering herd on recovery.&lt;/p&gt;

&lt;p&gt;If you're running a service that clients depend on, it's worth thinking about what their retry behavior looks like. Are they using exponential backoff? Do they have a circuit breaker? Or are they just spinning on a 1-second loop?&lt;/p&gt;

&lt;p&gt;And if you're the client, it's worth checking your default retry config. The defaults in many HTTP libraries are not kind to recovering services.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This was the GitHub Aug 17 outage lesson. Source: DevOps'ish #323 and GitHub CTO Vlad Fedorov's postmortem.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>devops</category>
      <category>sre</category>
      <category>systemdesign</category>
    </item>
  </channel>
</rss>
