<?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>Forem: Alex Fler</title>
    <description>The latest articles on Forem by Alex Fler (@ops_mechanic).</description>
    <link>https://forem.com/ops_mechanic</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3707865%2F1127299b-e588-40d8-bf42-ef87f74e6a7e.png</url>
      <title>Forem: Alex Fler</title>
      <link>https://forem.com/ops_mechanic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ops_mechanic"/>
    <language>en</language>
    <item>
      <title>Stop Context-Switching to Check SSL Certs — Do It From Emacs</title>
      <dc:creator>Alex Fler</dc:creator>
      <pubDate>Thu, 26 Feb 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/ops_mechanic/stop-context-switching-to-check-ssl-certs-do-it-from-emacs-49ig</link>
      <guid>https://forem.com/ops_mechanic/stop-context-switching-to-check-ssl-certs-do-it-from-emacs-49ig</guid>
      <description>&lt;p&gt;If you spend most of your day in Emacs and manage any amount of infrastructure, you’ve felt this friction: you need to check a cert, so you alt-tab to a terminal, type out an &lt;code&gt;openssl s_client&lt;/code&gt; incantation you can never quite remember, parse the wall of output, and then switch back to whatever you were doing. The context switch is small but constant, and it adds up.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/FlerAlex/certradar-cli" rel="noopener noreferrer"&gt;certradar-cli&lt;/a&gt; — a Rust-based SSL/TLS analysis tool — partly to solve this. But it got way more useful once I wired it into Emacs with a few lines of elisp. Now cert checks are one keystroke away, inline with my workflow, and I actually do them proactively instead of reactively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You’ll need certradar-cli installed. It’s a single Rust binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cargo install certradar-cli

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it’s working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;certradar-cli ssl example.com

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you’re on a system where &lt;code&gt;~/.cargo/bin&lt;/code&gt; isn’t on your PATH, you’ll also want to tell Emacs where to find it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(add-to-list 'exec-path "~/.cargo/bin")

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The &lt;code&gt;M-x&lt;/code&gt; Command
&lt;/h2&gt;

&lt;p&gt;This gives you a &lt;code&gt;check-ssl&lt;/code&gt; command that prompts for a domain and opens the results in a dedicated read-only buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(defun check-ssl--clean-domain (domain)
  "Strip protocol prefix and port suffix from DOMAIN."
  (let ((cleaned (replace-regexp-in-string "https?://" "" domain)))
    (replace-regexp-in-string ":[0-9]+$" "" cleaned)))

(defun check-ssl (domain)
  "Check SSL/TLS certificate for DOMAIN using certradar-cli."
  (interactive "sDomain: ")
  (let* ((clean-domain (check-ssl--clean-domain domain))
         (buf-name (format "*SSL: %s*" clean-domain))
         (buf (get-buffer-create buf-name)))
    (with-current-buffer buf
      (read-only-mode -1)
      (erase-buffer)
      (insert (format "SSL Certificate Report: %s\n" clean-domain))
      (insert (make-string 50 ?─))
      (insert "\n\n")
      (let ((result (shell-command-to-string
                     (format "certradar-cli ssl --no-color %s 2&amp;gt;&amp;amp;1"
                             (shell-quote-argument clean-domain)))))
        (insert result))
      (goto-char (point-min))
      (special-mode))
    (switch-to-buffer-other-window buf)))

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage: &lt;code&gt;M-x check-ssl RET example.com RET&lt;/code&gt;. You get a buffer you can scroll, search with &lt;code&gt;C-s&lt;/code&gt;, or yank text from. The buffer is in &lt;code&gt;special-mode&lt;/code&gt; so you won’t accidentally edit the output.&lt;/p&gt;

&lt;p&gt;The domain cleanup helper strips both &lt;code&gt;https://&lt;/code&gt; prefixes and &lt;code&gt;:443&lt;/code&gt; port suffixes, so you can paste &lt;code&gt;https://example.com:8443/path&lt;/code&gt; and it just works. &lt;code&gt;--no-color&lt;/code&gt; keeps ANSI escape codes out of the buffer — without it, Rust CLI tools tend to dump &lt;code&gt;^[[32m&lt;/code&gt; garbage into your output. &lt;code&gt;shell-quote-argument&lt;/code&gt; prevents injection if you’re ever passing untrusted input. &lt;code&gt;switch-to-buffer-other-window&lt;/code&gt; keeps your current buffer visible — useful when you’re checking a cert referenced in the code you’re reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check the Domain Under Your Cursor
&lt;/h3&gt;

&lt;p&gt;If you keep domain lists in org files, Ansible inventories, Nginx configs, or anywhere else, this function grabs whatever’s under point and checks it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(defun check-ssl-at-point ()
  "Check SSL cert for the domain under point."
  (interactive)
  (let ((domain (thing-at-point 'url t)))
    (if domain
        (check-ssl domain)
      (let ((word (thing-at-point 'symbol t)))
        (if (and word (string-match-p "\\." word))
            (check-ssl word)
          (call-interactively #'check-ssl))))))

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It tries to grab a full URL at point first, then falls back to the symbol at point if it contains a dot (probably a domain), and prompts you if neither works. No need to strip the protocol here — &lt;code&gt;check-ssl--clean-domain&lt;/code&gt; handles all of that.&lt;/p&gt;

&lt;p&gt;Bind it somewhere fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(global-set-key (kbd "C-c s") #'check-ssl-at-point)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cursor on domain → &lt;code&gt;C-c s&lt;/code&gt; → cert report. No context switch, no copy-paste, no remembering flags.&lt;/p&gt;

&lt;h3&gt;
  
  
  Async Variant (Don’t Freeze Emacs)
&lt;/h3&gt;

&lt;p&gt;One problem with the version above: &lt;code&gt;shell-command-to-string&lt;/code&gt; is blocking. If a domain is hanging or DNS is slow, Emacs locks up until it times out. That’s fine for fast lookups, but if you’re checking anything over a flaky connection, it’s painful.&lt;/p&gt;

&lt;p&gt;Here’s the same thing using &lt;code&gt;make-process&lt;/code&gt; so Emacs stays responsive while the cert is fetched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(defun check-ssl-async (domain)
  "Check SSL/TLS certificate for DOMAIN asynchronously."
  (interactive "sDomain: ")
  (let* ((clean-domain (check-ssl--clean-domain domain))
         (buf-name (format "*SSL: %s*" clean-domain))
         (buf (get-buffer-create buf-name)))
    (with-current-buffer buf
      (read-only-mode -1)
      (erase-buffer)
      (insert (format "SSL Certificate Report: %s\n" clean-domain))
      (insert (make-string 50 ?─))
      (insert "\n\nFetching...\n"))
    (switch-to-buffer-other-window buf)
    (make-process
     :name (format "certradar-%s" clean-domain)
     :buffer buf
     :command (list "certradar-cli" "ssl" "--no-color" clean-domain)
     :sentinel (lambda (proc _event)
                 (when (eq (process-status proc) 'exit)
                   (with-current-buffer (process-buffer proc)
                     (goto-char (point-min))
                     (when (search-forward "Fetching..." nil t)
                       (replace-match ""))
                     (special-mode)))))))

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The buffer pops open immediately with “Fetching…”, and the output streams in as it arrives. You can keep typing in other buffers while it runs. If you want this as your default, just rebind &lt;code&gt;C-c s&lt;/code&gt; to call &lt;code&gt;check-ssl-async&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Org-Babel for Runbooks
&lt;/h2&gt;

&lt;p&gt;If you use org-mode for operational documentation — and if you’re running infrastructure, you really should — this embeds cert checks directly into your notes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Single Domain Check
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#+NAME: ssl-check
#+BEGIN_SRC shell :var domain="example.com" :results output
certradar-cli ssl "$domain"
#+END_SRC

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Place your cursor inside the block and hit &lt;code&gt;C-c C-c&lt;/code&gt;. The output appears inline, right below the block. Change the &lt;code&gt;:var domain=&lt;/code&gt; value and run it again. The results become part of your document — perfect for incident write-ups, audit trails, or change management records.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch Checking Multiple Domains
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#+NAME: ssl-batch
#+BEGIN_SRC shell :results output
for domain in example.com github.com expired.badssl.com; do
    echo "=== $domain ==="
    certradar-cli ssl "$domain"
    echo ""
done
#+END_SRC

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep a &lt;code&gt;domains.org&lt;/code&gt; file with all the certs I’m responsible for. During maintenance windows, I run through the batch blocks and the results stay versioned in the file. It’s not a replacement for proper monitoring, but it’s a great complement — especially when you need evidence that you actually checked things.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Works So Well
&lt;/h3&gt;

&lt;p&gt;The real value isn’t the execution itself — it’s that your checks live next to your context. You’re writing up an incident and need to verify the cert state? You don’t leave the document. Onboarding someone and want to show them how to verify certs? The runbook runs. Everything stays in one place, reproducible and version-controlled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background Expiry Monitoring
&lt;/h2&gt;

&lt;p&gt;This one is for anyone who’s been woken up at 3am because a cert expired silently. It runs a background timer that checks your critical domains and warns you inside Emacs when certs are getting close:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(defvar ssl-watch-domains '("yourdomain.com" "api.yourdomain.com")
  "Domains to monitor for certificate expiry.")

(defvar ssl-expiry-warning-days 14
  "Warn when cert expires within this many days.")

(defun ssl-check-expiry-warnings ()
  "Check watched domains and message any upcoming expirations."
  (dolist (domain ssl-watch-domains)
    (let ((output (shell-command-to-string
                   (format "certradar-cli ssl --no-color %s --json 2&amp;gt;/dev/null"
                           (shell-quote-argument domain)))))
      (when (string-match "\"days_until_expiry\":\\s*\\([0-9]+\\)" output)
        (let ((days (string-to-number (match-string 1 output))))
          (when (&amp;lt;= days ssl-expiry-warning-days)
            (message "SSL WARNING: %s expires in %d days!" domain days)
            (run-with-timer 0.5 nil
              (lambda (d n)
                (display-warning 'ssl
                  (format "%s certificate expires in %d days" d n)
                  :warning))
              domain days)))))))

;; Check once on startup, then every 6 hours
(ssl-check-expiry-warnings)
(run-with-timer 21600 21600 #'ssl-check-expiry-warnings)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, this Emacs timer is fine for a handful of domains you want to keep an eye on locally. But if you’re managing infrastructure at any real scale, you probably want something that doesn’t depend on your editor being open. That’s why I also built &lt;a href="https://certradar.net" rel="noopener noreferrer"&gt;CertRadar.net&lt;/a&gt; — it gives you a centralized dashboard with certificate transparency log monitoring, chain validation, and configuration analysis across your entire infrastructure. The stuff that falls through the cracks — the staging environment cert nobody’s watching, the internal tool with a self-signed cert someone issued a year ago, the domain your team inherited last quarter — CertRadar catches that.&lt;/p&gt;

&lt;p&gt;I use both. The CLI for “let me check this right now” and CertRadar for “make sure nothing expires or misconfigures while I’m not looking.”&lt;/p&gt;

&lt;p&gt;The timer checks every 6 hours (21600 seconds) and uses &lt;code&gt;display-warning&lt;/code&gt; to surface alerts in the &lt;code&gt;*Warnings*&lt;/code&gt; buffer, which is hard to miss. Adjust &lt;code&gt;ssl-expiry-warning-days&lt;/code&gt; based on your renewal workflow — 14 days works for automated Let’s Encrypt setups, but you might want 30 or 60 if your certs require manual renewal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation: The Full Package
&lt;/h2&gt;

&lt;p&gt;I’ve packaged all of the above into a single &lt;code&gt;certradar.el&lt;/code&gt; file you can drop into your config. It also includes a &lt;code&gt;check-ssl-batch&lt;/code&gt; interactive command and &lt;code&gt;ssl-watch-start&lt;/code&gt; / &lt;code&gt;ssl-watch-stop&lt;/code&gt; functions for managing the expiry timer.&lt;/p&gt;

&lt;p&gt;Grab it from the &lt;a href="https://github.com/FlerAlex/certradar-cli" rel="noopener noreferrer"&gt;certradar-cli repository&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Download &lt;code&gt;certradar.el&lt;/code&gt; into &lt;code&gt;~/.emacs.d/lisp/&lt;/code&gt; (create the directory if it doesn’t exist)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add this to your &lt;code&gt;init.el&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(add-to-list 'load-path (expand-file-name "lisp" user-emacs-directory))
(require 'certradar)

;; Keybindings
(global-set-key (kbd "C-c s") #'check-ssl-at-point)
(global-set-key (kbd "C-c S") #'check-ssl-batch)

;; Optional: enable the expiry watcher
;; (setq ssl-watch-domains '("yourdomain.com" "api.yourdomain.com"))
;; (ssl-watch-start)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Restart Emacs or evaluate your init.el with &lt;code&gt;M-x eval-buffer&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why Bother
&lt;/h2&gt;

&lt;p&gt;The actual time saved per cert check is maybe 10 seconds. That’s not the point. Removing the friction changes your behavior. When checking a cert is one keystroke away, you check certs. When it requires switching contexts, opening a terminal, and remembering syntax, you don’t — or at least not as often as you should.&lt;/p&gt;

&lt;p&gt;28 years into this career, the tools I actually use are the ones embedded in my workflow, not the ones bookmarked in my browser. If Emacs is where you live, your cert checks should live there too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://github.com/FlerAlex/certradar-cli" rel="noopener noreferrer"&gt;certradar-cli&lt;/a&gt; is free and open source. If you want a full web-based dashboard for monitoring certs across all your domains, check out &lt;a href="https://certradar.net" rel="noopener noreferrer"&gt;CertRadar.net&lt;/a&gt;. For ongoing SSL monitoring with alerts, there’s &lt;a href="https://sslguard.net" rel="noopener noreferrer"&gt;SSLGuard.net&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>security</category>
      <category>showdev</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Where Did That Environment Variable Come From?</title>
      <dc:creator>Alex Fler</dc:creator>
      <pubDate>Fri, 13 Feb 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/ops_mechanic/where-did-that-environment-variable-come-from-1aak</link>
      <guid>https://forem.com/ops_mechanic/where-did-that-environment-variable-come-from-1aak</guid>
      <description>&lt;p&gt;“It works in my terminal but not in cron.”&lt;/p&gt;

&lt;p&gt;If you’ve been doing this long enough, that sentence alone is enough to make your eye twitch. You know exactly what’s coming: an hour of grepping through dotfiles, manually reading shell startup chains in your head, and eventually finding that some vendor install script shoved a &lt;code&gt;PATH&lt;/code&gt; override into &lt;code&gt;/etc/profile.d/java-vendor-garbage.sh&lt;/code&gt; three years ago and nobody noticed because interactive shells got the right value from &lt;code&gt;~/.zshrc&lt;/code&gt; anyway.&lt;/p&gt;

&lt;p&gt;I’ve done this dance hundreds of times. Literally hundreds. And every single time it’s the same tedious archaeology: which files does this shell context actually read, in what order, and which one of them is clobbering my variable?&lt;/p&gt;

&lt;p&gt;So I finally wrote a tool to do it for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shell Startup Problem
&lt;/h2&gt;

&lt;p&gt;If you already know how shell startup files work, skip ahead. If you don’t, buckle up, because this is where most env var bugs come from and nobody teaches it properly.&lt;/p&gt;

&lt;p&gt;When you open a terminal on macOS, zsh reads &lt;em&gt;seven&lt;/em&gt; files in a specific order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/etc/zshenv → /etc/zprofile → ~/.zshenv → ~/.zprofile → /etc/zshrc → ~/.zshrc → ~/.zlogin

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bash on Linux does its own thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/etc/profile → /etc/profile.d/*.sh → ~/.bash_profile → ~/.bashrc

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s just for login shells. An interactive non-login shell? Different chain. A cron job? Skips almost everything—you might only get &lt;code&gt;/etc/environment&lt;/code&gt; and whatever the crontab itself sets. systemd services? They have &lt;code&gt;environment.d&lt;/code&gt; drop-in directories. And macOS launchd agents live in their own plist-based dimension that has nothing to do with your shell at all.&lt;/p&gt;

&lt;p&gt;Every one of these contexts can set, override, append to, or &lt;code&gt;unset&lt;/code&gt; the same variable. A variable can enter &lt;code&gt;~/.zshenv&lt;/code&gt; as &lt;code&gt;/usr/bin&lt;/code&gt;, get &lt;code&gt;/usr/local/bin&lt;/code&gt; prepended in &lt;code&gt;/etc/zprofile&lt;/code&gt;, pick up &lt;code&gt;/opt/homebrew/bin&lt;/code&gt; in &lt;code&gt;~/.zshrc&lt;/code&gt;, and then get completely overwritten by some ancient &lt;code&gt;~/.zlogin&lt;/code&gt; that hardcodes a value from 2021.&lt;/p&gt;

&lt;p&gt;The “it works on my machine” of environment variables is really “it works in my specific shell context.”&lt;/p&gt;

&lt;h2&gt;
  
  
  envtrace
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/FlerAlex/envtrace" rel="noopener noreferrer"&gt;envtrace&lt;/a&gt; walks the actual startup file chain for your platform and context, and shows you every file that touches a given variable. That’s it. No magic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cargo install envtrace


$ envtrace PATH

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a chronological trace: file, line number, operation (set, export, append, prepend, unset, conditional), and the resulting value after each step. Every file in the chain that doesn’t touch your variable gets skipped, unless you pass &lt;code&gt;--verbose&lt;/code&gt; and want to see the full list of files it checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The “Works in Terminal, Breaks in Cron” Fix
&lt;/h2&gt;

&lt;p&gt;Here’s the scenario that made me actually write this thing.&lt;/p&gt;

&lt;p&gt;A Java developer files a ticket: “JAVA_HOME is wrong in the CI runner.” I SSH into the box. &lt;code&gt;echo $JAVA_HOME&lt;/code&gt; gives me &lt;code&gt;/usr/lib/jvm/java-17&lt;/code&gt;. Correct. I trigger the CI job manually. It picks up Java 11. What?&lt;/p&gt;

&lt;p&gt;The answer, as always, is context. My interactive login shell reads &lt;code&gt;~/.zshrc&lt;/code&gt;, which sources an internal &lt;code&gt;java-env.sh&lt;/code&gt; that exports the Java 17 path. The CI runner spawns a non-interactive non-login shell. It never touches &lt;code&gt;~/.zshrc&lt;/code&gt;. It gets its &lt;code&gt;JAVA_HOME&lt;/code&gt; from &lt;code&gt;/etc/profile&lt;/code&gt;, which still points to Java 11 because nobody updated it after the migration six months ago.&lt;/p&gt;

&lt;p&gt;This is not a bug. This is how Unix shells have always worked. But figuring this out by hand means you have to know which files each context reads, then manually read each one, then mentally track the value as it mutates through the chain. Every time.&lt;/p&gt;

&lt;p&gt;Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ envtrace -C login,cron JAVA_HOME

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-C&lt;/code&gt; flag compares a variable across multiple contexts side by side. You see the full trace for each context and exactly where the values diverge. This one flag alone would have saved me more hours than I’m comfortable admitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing Functions
&lt;/h2&gt;

&lt;p&gt;Variables aren’t the only casualties. Shell functions get sourced, overridden, and &lt;code&gt;unset -f&lt;/code&gt;’d through the same chain. If you’ve ever wondered why &lt;code&gt;nvm&lt;/code&gt; is suddenly not a function after some dotfile change, this is for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ envtrace -F nvm

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Picks up POSIX function syntax, bash &lt;code&gt;function&lt;/code&gt; keyword syntax, zsh &lt;code&gt;autoload&lt;/code&gt; declarations, and &lt;code&gt;unset -f&lt;/code&gt; removals. Same chronological trace, same file chain awareness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding Things When You Have No Idea
&lt;/h2&gt;

&lt;p&gt;Sometimes you don’t even know which context is relevant. You just know &lt;em&gt;something somewhere&lt;/em&gt; is setting &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt; to a directory that was decommissioned in the Obama administration and you need to find it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ envtrace --find LD_LIBRARY_PATH

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ignores context entirely, searches every config file it knows about, and shows you every mention. Broad net.&lt;/p&gt;

&lt;h2&gt;
  
  
  PATH Health Checks
&lt;/h2&gt;

&lt;p&gt;While I was in there, I added a sanity checker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ envtrace --check

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scans your &lt;code&gt;PATH&lt;/code&gt; for the usual sins: directories that don’t exist, duplicates, empty entries from stray &lt;code&gt;::&lt;/code&gt; separators. On macOS, it also compares what your shell sees against what &lt;code&gt;launchd&lt;/code&gt; provides to GUI apps—which is the root cause of every “it works in Terminal but not in VS Code” bug report you’ve ever gotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the Hood
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about what this is and isn’t. envtrace is &lt;em&gt;not&lt;/em&gt; a shell parser. Fully parsing bash is a Lovecraftian horror that I am not interested in. What it does is pattern-match against the forms people actually use in dotfiles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;export VAR=value&lt;/code&gt; and &lt;code&gt;VAR=value&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VAR="$VAR:/new/path"&lt;/code&gt; (append) and &lt;code&gt;VAR="/new/path:$VAR"&lt;/code&gt; (prepend)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unset VAR&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[-f /something] &amp;amp;&amp;amp; export VAR=value&lt;/code&gt; (conditional)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;source&lt;/code&gt; and &lt;code&gt;.&lt;/code&gt; directives (followed recursively, with circular-include detection so your weird dotfile setup doesn’t send it into an infinite loop)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This covers the vast majority of what you’ll find in real startup files. If you have some baroque &lt;code&gt;eval $(generate_env_dynamically.py)&lt;/code&gt; situation, envtrace won’t help you and frankly nothing will.&lt;/p&gt;

&lt;p&gt;It spits out JSON too (&lt;code&gt;--format json&lt;/code&gt;) if you want to pipe it into &lt;code&gt;jq&lt;/code&gt; or build something on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cargo install envtrace

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or grab a binary from the &lt;a href="https://github.com/FlerAlex/envtrace/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;. Needs Rust 1.85+ if you’re building from source.&lt;/p&gt;

&lt;p&gt;It runs on macOS (zsh) and Linux (bash). Those are the two platforms I care about and the two platforms where this problem actually exists.&lt;/p&gt;

&lt;p&gt;The next time you’re staring at a broken &lt;code&gt;PATH&lt;/code&gt; at 2 AM, trying to remember whether &lt;code&gt;/etc/profile&lt;/code&gt; runs before or after &lt;code&gt;~/.bash_profile&lt;/code&gt; (it does), and whether cron even reads either of them (it doesn’t)—just trace it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/FlerAlex/envtrace" rel="noopener noreferrer"&gt;&lt;strong&gt;envtrace on GitHub&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://crates.io/crates/envtrace" rel="noopener noreferrer"&gt;&lt;strong&gt;envtrace on crates.io&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>bash</category>
      <category>cli</category>
      <category>devops</category>
      <category>linux</category>
    </item>
    <item>
      <title>GPG Isn’t Broken, You’re Just Using It Wrong</title>
      <dc:creator>Alex Fler</dc:creator>
      <pubDate>Sun, 25 Jan 2026 16:19:17 +0000</pubDate>
      <link>https://forem.com/ops_mechanic/gpg-isnt-broken-youre-just-using-it-wrong-5358</link>
      <guid>https://forem.com/ops_mechanic/gpg-isnt-broken-youre-just-using-it-wrong-5358</guid>
      <description>&lt;p&gt;I’ve been managing keys since 1997—started on heavy iron back when "remote troubleshooting" meant driving to the data center at 2am. In that time, I’ve seen GnuPG called everything from "obsolete" to "impossible." The truth is, most of the headaches come from treating 2026 infrastructure like it's still 1999.&lt;/p&gt;

&lt;p&gt;People solve the same thorny problems over and over, usually by making things more complicated than they need to be. If you are still manually syncing private keys across five different laptops via USB drive, you are doing it wrong.&lt;/p&gt;

&lt;p&gt;Here is the pragmatic, "physics wins" approach to OpenPGP that I actually use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Philosophy: Cattle, Not Pets
&lt;/h2&gt;

&lt;p&gt;We learned long ago in Ops to treat servers like cattle, not pets. Yet, we still treat GPG subkeys like precious heirlooms.&lt;/p&gt;

&lt;p&gt;PGP keys have four functions. Two of them—Signing and Authenticating—are fungible. It does not matter which subkey signs your commit, as long as it ties back to your primary identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Rule:&lt;/strong&gt; Generate a new signing/auth subkey for every single device you own.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Laptop died? Revoke that specific subkey.&lt;/li&gt;
&lt;li&gt;  New workstation? Generate a new subkey.&lt;/li&gt;
&lt;li&gt;  Lost access? Who cares? They are trivially replaceable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Identity Crisis (UIDs)
&lt;/h2&gt;

&lt;p&gt;A common mistake I see in r/devops is the "Omnibus Key"—one key with fifteen different email addresses attached.&lt;/p&gt;

&lt;p&gt;Your primary key is your root identity. If you are signing Git commits for your employer, do not use your personal identity key.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Create a specific Primary Key for work. Use your work email as the UID.&lt;/li&gt;
&lt;li&gt;  Create subkeys off that primary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; Your work signatures are tied only to your work identity.&lt;/p&gt;

&lt;p&gt;If you leave that job, you revoke the whole key. Clean break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Configuration You Actually Need
&lt;/h2&gt;

&lt;p&gt;Default GnuPG settings are suboptimal. We want a clean Ed25519 setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Create a Cert-Only Primary Key
&lt;/h3&gt;

&lt;p&gt;We want a key that can only certify (manage) other keys.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--quick-generate-key&lt;/span&gt; &lt;span class="s2"&gt;"Your Name &amp;lt;you@work.com&amp;gt;"&lt;/span&gt; ed25519 cert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Create Your Subkeys
&lt;/h3&gt;

&lt;p&gt;Add the "cattle" keys. We add a signing key for Git, and an encryption key so colleagues can send you secrets (like initial passwords) securely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt;
gpg&amp;gt; addkey
&lt;span class="c"&gt;# Select (10) ECC (sign only) -&amp;gt; Ed25519&lt;/span&gt;
&lt;span class="c"&gt;# Valid for? Enter "2y"&lt;/span&gt;
gpg&amp;gt; addkey
&lt;span class="c"&gt;# Select (12) ECC (encrypt only) -&amp;gt; Cv25519&lt;/span&gt;
&lt;span class="c"&gt;# Valid for? Enter "2y"&lt;/span&gt;
gpg&amp;gt; save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Operational Hygiene: The "Kill Switch"
&lt;/h2&gt;

&lt;p&gt;Before you go any further, generate a revocation certificate.&lt;/p&gt;

&lt;p&gt;I once spent a holiday weekend trying to recover a key I'd "safely" stored on a USB drive... which turned out to be an encrypted volume where the password was stored inside that same volume. I had to burn the identity and start over.&lt;/p&gt;

&lt;p&gt;Don't be me. Generate the revocation cert now and print it out on actual paper.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--output&lt;/span&gt; revoke.asc &lt;span class="nt"&gt;--gen-revoke&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Expiration Strategy
&lt;/h3&gt;

&lt;p&gt;I set my subkeys to expire in 2 years. I have a calendar reminder to rotate them annually. To extend a key (when your annual reminder pops up), you must select the specific subkey first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt;
gpg&amp;gt; key 1        &lt;span class="c"&gt;# Select signing subkey&lt;/span&gt;
gpg&amp;gt; expire       &lt;span class="c"&gt;# Enter "2y"&lt;/span&gt;
gpg&amp;gt; key 1        &lt;span class="c"&gt;# Deselect&lt;/span&gt;
gpg&amp;gt; key 2        &lt;span class="c"&gt;# Select encryption subkey&lt;/span&gt;
gpg&amp;gt; expire       &lt;span class="c"&gt;# Enter "2y"&lt;/span&gt;
gpg&amp;gt; save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The "One True Source"
&lt;/h2&gt;

&lt;p&gt;This is the part that fixes 90% of the "I can't verify this signature" errors.&lt;/p&gt;

&lt;p&gt;Forget public keyservers. They are a mess of spam. The OpenPGP spec allows you to define a preferred-keyserver. It lets you say: "If you want my key, look HERE."&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Set the Preference
&lt;/h3&gt;

&lt;p&gt;Tell the key where it lives using the keyserver subcommand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt;
gpg&amp;gt; keyserver https://keys.theopsmechanic.com/0xYOURFINGERPRINT.asc
gpg&amp;gt; save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Host It
&lt;/h3&gt;

&lt;p&gt;Export the key and push it to your static site (Nginx, Cloudflare Pages, S3, etc).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; static/mykey.asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Offline the Master Key
&lt;/h2&gt;

&lt;p&gt;People say "keep your master key offline" but rarely explain the mechanics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 0:&lt;/strong&gt; BACK UP EVERYTHING. Copy your entire &lt;code&gt;~/.gnupg&lt;/code&gt; directory to an encrypted USB drive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Remove the Secret Key&lt;br&gt;
We need to remove the master secret key from your laptop while keeping the subkeys active.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Export your secret SUBKEYS (this strips the master secret key)&lt;/span&gt;
gpg &lt;span class="nt"&gt;--export-secret-subkeys&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; subkeys.gpg

&lt;span class="c"&gt;# 2. Delete the secret key entirely from your keyring&lt;/span&gt;
gpg &lt;span class="nt"&gt;--delete-secret-key&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt;

&lt;span class="c"&gt;# 3. Import the subkeys back in&lt;/span&gt;
gpg &lt;span class="nt"&gt;--import&lt;/span&gt; subkeys.gpg

&lt;span class="c"&gt;# 4. Clean up the temp file&lt;/span&gt;
&lt;span class="nb"&gt;shred&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; subkeys.gpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Verify&lt;br&gt;
Run &lt;code&gt;gpg -K&lt;/code&gt; (list secret keys). Look for the &lt;code&gt;#&lt;/code&gt; symbol next to &lt;code&gt;sec&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;sec#  ed25519 2026-01-11 [C]  &amp;lt;-- The '#' means the secret key is NOT here. Good.
ssb   ed25519 2026-01-11 [S]  &amp;lt;-- Signing subkey present.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;sec#&lt;/code&gt;, you succeeded. Your laptop can sign commits, but it cannot create new subkeys or revoke your identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making &lt;code&gt;git log&lt;/code&gt; Actually Useful
&lt;/h2&gt;

&lt;p&gt;We can fix "No Public Key" errors by embedding the URL to your key inside every signature you make.&lt;/p&gt;

&lt;p&gt;Add this to your local GPG config:&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;# ~/.gnupg/gpg.conf&lt;/span&gt;
sig-keyserver-url https://keys.theopsmechanic.com/0xYOURFINGERPRINT.asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Catch:&lt;/strong&gt; For this to work automatically, the verifier needs &lt;code&gt;keyserver-options auto-key-retrieve&lt;/code&gt; enabled. Even without it, the URL provides a clear breadcrumb to solve the problem manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cheat Sheet
&lt;/h2&gt;

&lt;p&gt;If you skipped the prose, here is the lifecycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Generate &amp;amp; Configure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create Cert-Only Master&lt;/span&gt;
gpg &lt;span class="nt"&gt;--quick-generate-key&lt;/span&gt; &lt;span class="s2"&gt;"User &amp;lt;email&amp;gt;"&lt;/span&gt; ed25519 cert

&lt;span class="c"&gt;# Add Subkeys &amp;amp; URL&lt;/span&gt;
gpg &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &amp;lt;FINGERPRINT&amp;gt;
gpg&amp;gt; addkey &lt;span class="c"&gt;# Select Sign-Only -&amp;gt; "2y" expiry&lt;/span&gt;
gpg&amp;gt; addkey &lt;span class="c"&gt;# Select Encrypt-Only -&amp;gt; "2y" expiry&lt;/span&gt;
gpg&amp;gt; keyserver https://your-domain.com/key.asc
gpg&amp;gt; save

&lt;span class="c"&gt;# EMERGENCY BRAKE (Do not skip)&lt;/span&gt;
gpg &lt;span class="nt"&gt;--output&lt;/span&gt; revoke.asc &lt;span class="nt"&gt;--gen-revoke&lt;/span&gt; &amp;lt;FINGERPRINT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Offline Master
&lt;/h3&gt;

&lt;p&gt;This is the dangerous part. Focus.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--export-secret-subkeys&lt;/span&gt; &amp;lt;FINGERPRINT&amp;gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; subkeys.gpg
gpg &lt;span class="nt"&gt;--delete-secret-key&lt;/span&gt; &amp;lt;FINGERPRINT&amp;gt;
gpg &lt;span class="nt"&gt;--import&lt;/span&gt; subkeys.gpg
&lt;span class="nb"&gt;shred&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; subkeys.gpg
&lt;span class="c"&gt;# Verify: gpg -K must show 'sec#'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Distribute &amp;amp; Use
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Upload&lt;/span&gt;
gpg &lt;span class="nt"&gt;--armor&lt;/span&gt; &lt;span class="nt"&gt;--export&lt;/span&gt; &amp;lt;FINGERPRINT&amp;gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; key.asc
&lt;span class="c"&gt;# (Upload to https://your-domain.com/key.asc)&lt;/span&gt;

&lt;span class="c"&gt;# Config&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"sig-keyserver-url https://your-domain.com/key.asc"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.gnupg/gpg.conf
git config &lt;span class="nt"&gt;--global&lt;/span&gt; user.signingkey &amp;lt;SUBKEY_FINGERPRINT&amp;gt;
git config &lt;span class="nt"&gt;--global&lt;/span&gt; commit.gpgsign &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prior Art &amp;amp; Further Reading
&lt;/h2&gt;

&lt;p&gt;I am standing on the shoulders of giants (and paranoid sysadmins) here. If you want to go deeper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Debian Wiki: Offline Master Key&lt;/strong&gt; — The original bible on splitting master and subkeys.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;DrDuh's YubiKey Guide&lt;/strong&gt; — If you want to take this logic and apply it to hardware tokens (highly recommended for high-security contexts).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Set it up once, correctly. Physics wins.&lt;/p&gt;

</description>
      <category>security</category>
      <category>encryption</category>
      <category>linux</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Your SSH Keys Are Naked and It's Your Fault</title>
      <dc:creator>Alex Fler</dc:creator>
      <pubDate>Sun, 25 Jan 2026 16:16:40 +0000</pubDate>
      <link>https://forem.com/ops_mechanic/your-ssh-keys-are-naked-and-its-your-fault-32kp</link>
      <guid>https://forem.com/ops_mechanic/your-ssh-keys-are-naked-and-its-your-fault-32kp</guid>
      <description>&lt;p&gt;I've been SSHing into production boxes since before most junior devs were born. In that time, I've seen the same sins repeated across every generation of sysadmins: naked private keys sitting in &lt;code&gt;~/.ssh&lt;/code&gt; with no passphrase, copy-pasted across laptops, synced to Dropbox (yes, really), and generally treated with the same care as a grocery list.&lt;/p&gt;

&lt;p&gt;If your SSH private key doesn't have a passphrase on it right now, you're one stolen laptop away from a very bad day.&lt;/p&gt;

&lt;p&gt;Here's the setup I actually use—one that treats SSH authentication like the serious business it is, without making you type passwords fifty times a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Naked Keys Everywhere
&lt;/h2&gt;

&lt;p&gt;Let me paint you a picture I've seen a dozen times.&lt;/p&gt;

&lt;p&gt;Developer gets new laptop. Developer runs &lt;code&gt;ssh-keygen&lt;/code&gt;, hammers Enter through the passphrase prompts because "it's annoying," and copies &lt;code&gt;id_rsa.pub&lt;/code&gt; to fifteen different servers. Developer feels productive.&lt;/p&gt;

&lt;p&gt;Six months later, laptop gets stolen from a coffee shop. Thief now has root access to production. Developer updates LinkedIn to "exploring new opportunities."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The math is simple:&lt;/strong&gt; An unencrypted private key is a plaintext password sitting on your disk. Would you store &lt;code&gt;root:hunter2&lt;/code&gt; in a file called &lt;code&gt;passwords.txt&lt;/code&gt;? Then why is your SSH key any different?&lt;/p&gt;

&lt;h2&gt;
  
  
  "But Passphrases Are Annoying"
&lt;/h2&gt;

&lt;p&gt;I hear this constantly. And you know what? You're right. Typing a 20-character passphrase every time you SSH somewhere is annoying. That's why we have &lt;code&gt;ssh-agent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The agent holds your decrypted key in memory. You type your passphrase once when you start your session, and the agent handles every subsequent connection. This is not new technology—it has existed since the 90s.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2h1g3elcp2f9mk4gkgu7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2h1g3elcp2f9mk4gkgu7.png" alt="How ssh-agent caches your decrypted key in memory" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting Up ssh-agent (The Basics)
&lt;/h3&gt;

&lt;p&gt;Most modern systems start an agent automatically. Check if you have one running:&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="nv"&gt;$SSH_AUTH_SOCK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get a path back, you're set. If not, start one:&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;eval&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;ssh-agent &lt;span class="nt"&gt;-s&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add your key to the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll be prompted for your passphrase once. After that, every SSH connection uses the cached key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making It Persistent
&lt;/h3&gt;

&lt;p&gt;On Linux with systemd, enable the user agent service:&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="nt"&gt;--user&lt;/span&gt; &lt;span class="nb"&gt;enable &lt;/span&gt;ssh-agent
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; start ssh-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add to your shell profile:&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;# ~/.bashrc or ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$XDG_RUNTIME_DIR&lt;/span&gt;&lt;span class="s2"&gt;/ssh-agent.socket"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Watch out:&lt;/strong&gt; Some distros (looking at you, Ubuntu) start their own agent via X11 session scripts. You can end up with two agents fighting over &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt;. If keys aren't loading as expected, check &lt;code&gt;pgrep -a ssh-agent&lt;/code&gt; and kill any stragglers.&lt;/p&gt;

&lt;p&gt;On macOS, add keys to the Keychain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;--apple-use-keychain&lt;/span&gt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And configure SSH to use it by adding to &lt;code&gt;~/.ssh/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;Host *
    AddKeysToAgent yes
    UseKeychain yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now your passphrase survives reboots without you thinking about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going Further: GPG Keys for SSH (The Right Way)
&lt;/h2&gt;

&lt;p&gt;Here's where I lose half the audience. Stay with me.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Bother?
&lt;/h3&gt;

&lt;p&gt;If ssh-agent with a passphrase is "good enough," why complicate things with GPG?&lt;/p&gt;

&lt;p&gt;Because you're already maintaining GPG keys for Git signing (you &lt;strong&gt;ARE&lt;/strong&gt; signing your commits, right?). Adding SSH to that same identity means one backup, one rotation schedule, one mental model. And if you ever move to hardware tokens like YubiKeys, the GPG path gets you there with zero additional work—the YubiKey becomes your SSH key automatically.&lt;/p&gt;

&lt;p&gt;You already have a GPG key if you followed my &lt;a href="https://theopsmechanic.com/posts/gpg-key-management-done-right/" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;. That key can have an &lt;strong&gt;Authentication&lt;/strong&gt; subkey. Here's what you get:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One identity to rule them all.&lt;/strong&gt; Your GPG key signs commits, encrypts email, AND authenticates SSH. One key to back up, one key to rotate, one key to revoke.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hardware token support.&lt;/strong&gt; If you're using a YubiKey for GPG (and you should be), you get SSH authentication on hardware for free. The private key never touches your disk.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The "cattle not pets" model carries over.&lt;/strong&gt; Device-specific auth subkeys mean a compromised laptop doesn't compromise your entire SSH infrastructure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expiration is built in.&lt;/strong&gt; GPG subkeys have native expiry. Set your auth key to expire in 6 months—if it's compromised, the damage is time-boxed. Standard SSH keys never expire by default. You still need to update &lt;code&gt;authorized_keys&lt;/code&gt; files, but at least you have a forcing function.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 1: Enable SSH Support in GPG Agent
&lt;/h3&gt;

&lt;p&gt;The GPG agent can emulate &lt;code&gt;ssh-agent&lt;/code&gt;. We just need to tell it to.&lt;/p&gt;

&lt;p&gt;Add to &lt;code&gt;~/.gnupg/gpg-agent.conf&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;enable-ssh-support
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpgconf &lt;span class="nt"&gt;--kill&lt;/span&gt; gpg-agent
gpg-agent &lt;span class="nt"&gt;--daemon&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Point SSH at GPG Agent
&lt;/h3&gt;

&lt;p&gt;Add to your shell profile:&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;# ~/.bashrc or ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SSH_AUTH_SOCK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gpgconf &lt;span class="nt"&gt;--list-dirs&lt;/span&gt; agent-ssh-socket&lt;span class="si"&gt;)&lt;/span&gt;
gpgconf &lt;span class="nt"&gt;--launch&lt;/span&gt; gpg-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload your shell. Verify it's working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-add &lt;span class="nt"&gt;-L&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your GPG authentication subkey in SSH format.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Add an Authentication Subkey (If You Don't Have One)
&lt;/h3&gt;

&lt;p&gt;If you followed my GPG article, you have Signing and Encryption subkeys. Let's add Authentication.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt;
gpg&amp;gt; addkey
&lt;span class="c"&gt;# Select (10) ECC (sign only)... wait, that's wrong.&lt;/span&gt;
&lt;span class="c"&gt;# We need to enable expert mode for auth keys.&lt;/span&gt;
gpg&amp;gt; quit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me try that again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; &lt;span class="s2"&gt;"you@work.com"&lt;/span&gt;
gpg&amp;gt; addkey
&lt;span class="c"&gt;# Select (11) ECC (set your own capabilities)&lt;/span&gt;
&lt;span class="c"&gt;# Toggle off Sign, toggle on Authenticate&lt;/span&gt;
&lt;span class="c"&gt;# Select Curve 25519&lt;/span&gt;
&lt;span class="c"&gt;# Valid for? "2y"&lt;/span&gt;
gpg&amp;gt; save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Export the Public Key for SSH
&lt;/h3&gt;

&lt;p&gt;Get your authentication subkey's &lt;strong&gt;keygrip&lt;/strong&gt; (not the fingerprint—this trips people up):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--with-keygrip&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find the &lt;code&gt;[A]&lt;/code&gt; subkey (Authentication) and note its keygrip. It's the 40-character hex string on the line after the subkey.&lt;/p&gt;

&lt;p&gt;Export it in SSH format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--export-ssh-key&lt;/span&gt; you@work.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This outputs a standard &lt;code&gt;ssh-ed25519 AAAA...&lt;/code&gt; line. Add it to &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; on your servers, or to GitHub/GitLab.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Tell GPG Agent Which Key to Use
&lt;/h3&gt;

&lt;p&gt;Add the keygrip to &lt;code&gt;~/.gnupg/sshcontrol&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"YOURKEYGRIP 0"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.gnupg/sshcontrol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;0&lt;/code&gt; means "don't require confirmation for each use." Change it to &lt;code&gt;1&lt;/code&gt; if you're paranoid.&lt;/p&gt;

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

&lt;p&gt;Here's what your setup looks like now:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvespfmiuqbkn0nm85fke.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvespfmiuqbkn0nm85fke.png" alt="GPG subkey hierarchy for unified identity management" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One identity. Backed up once. Revocable from one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational Reality
&lt;/h2&gt;

&lt;p&gt;A few things I've learned running this setup for years:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation doesn't work like you think.&lt;/strong&gt; When you export a GPG key to SSH format, it's just a static public key string. The SSH daemon on your servers doesn't check GPG keyservers—it has no idea if you revoked the key. You still need to remove lines from &lt;code&gt;authorized_keys&lt;/code&gt; manually (or use something like LDAP/CA-based SSH). The win here is that GPG gives you a &lt;em&gt;single source of truth&lt;/em&gt; for what should be valid. When you revoke a subkey, you know what to clean up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent timeout matters.&lt;/strong&gt; By default, gpg-agent caches your passphrase for 600 seconds. Tune this in &lt;code&gt;gpg-agent.conf&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;default-cache-ttl 3600
max-cache-ttl 7200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use an hour for normal caching, two hours max. Adjust based on your paranoia level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forwarding works differently.&lt;/strong&gt; If you're used to &lt;code&gt;ssh -A&lt;/code&gt; for agent forwarding, it still works—but you're forwarding the GPG agent socket. This is actually more secure because you can configure which keys are allowed to be used remotely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YubiKey users get the best deal.&lt;/strong&gt; If your GPG key lives on a hardware token, your SSH authentication requires physical presence. No malware can exfiltrate what isn't on the disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cheat Sheet
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Encrypt Your Existing SSH Key (If You're Not Ready to Switch)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add passphrase to existing key&lt;/span&gt;
ssh-keygen &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/id_ed25519

&lt;span class="c"&gt;# Verify agent is running&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$SSH_AUTH_SOCK&lt;/span&gt;

&lt;span class="c"&gt;# Add key to agent&lt;/span&gt;
ssh-add ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Switch to GPG-Based SSH
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable SSH support&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"enable-ssh-support"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.gnupg/gpg-agent.conf

&lt;span class="c"&gt;# Update shell&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'gpgconf --launch gpg-agent'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc

&lt;span class="c"&gt;# Add auth subkey&lt;/span&gt;
gpg &lt;span class="nt"&gt;--expert&lt;/span&gt; &lt;span class="nt"&gt;--edit-key&lt;/span&gt; you@work.com
&lt;span class="c"&gt;# addkey -&amp;gt; ECC (set your own capabilities) -&amp;gt; Auth only -&amp;gt; Curve 25519 -&amp;gt; 2y&lt;/span&gt;

&lt;span class="c"&gt;# Get SSH public key&lt;/span&gt;
gpg &lt;span class="nt"&gt;--export-ssh-key&lt;/span&gt; you@work.com &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/authorized_keys_gpg

&lt;span class="c"&gt;# Find KEYGRIP (not fingerprint!) and add to sshcontrol&lt;/span&gt;
gpg &lt;span class="nt"&gt;-K&lt;/span&gt; &lt;span class="nt"&gt;--with-keygrip&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;A&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"KEYGRIP 0"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.gnupg/sshcontrol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Verify Everything Works
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List keys the agent knows about&lt;/span&gt;
ssh-add &lt;span class="nt"&gt;-L&lt;/span&gt;

&lt;span class="c"&gt;# Test connection&lt;/span&gt;
ssh &lt;span class="nt"&gt;-v&lt;/span&gt; user@server 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Offering public key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Prior Art &amp;amp; Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.gnupg.org/documentation/manuals/gnupg/Agent-Options.html" rel="noopener noreferrer"&gt;&lt;strong&gt;GnuPG Wiki: SSH Support&lt;/strong&gt;&lt;/a&gt; — The official documentation on gpg-agent SSH emulation.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://codeberg.org/Aviac/YubiKey-Guide" rel="noopener noreferrer"&gt;&lt;strong&gt;DrDuh's YubiKey Guide&lt;/strong&gt;&lt;/a&gt; — Again. If you're serious about this, put your keys on hardware.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.archlinux.org/title/GnuPG#SSH_agent" rel="noopener noreferrer"&gt;&lt;strong&gt;Arch Wiki: GnuPG/SSH agent&lt;/strong&gt;&lt;/a&gt; — Exhaustive documentation on every edge case.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stop treating SSH keys like disposable toys. Encrypt them, manage them properly, and ideally consolidate them under your GPG identity.&lt;/p&gt;

&lt;p&gt;Physics wins. Naked keys lose.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ssh</category>
      <category>linux</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>I Audited 50 SaaS Vendors' TLS Configurations — Here's What I Found</title>
      <dc:creator>Alex Fler</dc:creator>
      <pubDate>Sun, 25 Jan 2026 00:00:00 +0000</pubDate>
      <link>https://forem.com/ops_mechanic/i-audited-50-saas-vendors-tls-configurations-heres-what-i-found-3hph</link>
      <guid>https://forem.com/ops_mechanic/i-audited-50-saas-vendors-tls-configurations-heres-what-i-found-3hph</guid>
      <description>&lt;p&gt;I spent the weekend scanning the TLS configurations of 50 popular B2B SaaS tools using &lt;a href="https://certradar.net" rel="noopener noreferrer"&gt;CertRadar.net&lt;/a&gt;. My goal was simple: find out whether the vendors we trust with our data are actually practicing good TLS hygiene.&lt;/p&gt;

&lt;p&gt;The short answer? No disasters. The longer answer reveals some interesting gaps — particularly from vendors who &lt;em&gt;should&lt;/em&gt; know better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;I scanned the primary marketing domain for each vendor (e.g., &lt;code&gt;okta.com&lt;/code&gt;, &lt;code&gt;slack.com&lt;/code&gt;) using CertRadar’s SSL analyzer. I collected data on:&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How to Automate Let's Encrypt Renewal Monitoring on Nginx (The Robust Way)</title>
      <dc:creator>Alex Fler</dc:creator>
      <pubDate>Wed, 14 Jan 2026 00:24:06 +0000</pubDate>
      <link>https://forem.com/ops_mechanic/how-to-automate-lets-encrypt-renewal-monitoring-on-nginx-the-robust-way-3597</link>
      <guid>https://forem.com/ops_mechanic/how-to-automate-lets-encrypt-renewal-monitoring-on-nginx-the-robust-way-3597</guid>
      <description>&lt;p&gt;We've all been there. It's Monday morning, you haven't had your coffee yet, and Slack is blowing up.&lt;/p&gt;

&lt;p&gt;Users are seeing "Your connection is not private." The CTO is asking why the site is "hacked." Your boss's boss is cc'd on something. You check the box. Nginx is running. The app is fine. Load averages are low.&lt;/p&gt;

&lt;p&gt;Then it hits you: &lt;strong&gt;The Certbot cron job failed silently.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Maybe the ACME challenge timed out. Maybe Nginx didn't reload to pick up the new cert. Maybe DNS propagation was slow. Maybe Mercury was in retrograde. Doesn't matter—production is down because of a 90-day text file, and you look incompetent.&lt;/p&gt;

&lt;p&gt;In the old days, we bought Verisign certs that lasted 3 years and cost as much as a used car. Now we've traded dollars for DevOps anxiety—we have to be right every 90 days, forever. Thanks, Let's Encrypt. (I kid. Mostly.)&lt;/p&gt;

&lt;p&gt;Here's how to script your way out of that.&lt;/p&gt;




&lt;h2&gt;
  
  
  The "Hard Way": Bash, OpenSSL, and Grit
&lt;/h2&gt;

&lt;p&gt;I write a fair amount of Python and Rust these days, but when you need something to run on any box, anywhere, with zero dependencies? You go back to Bash. It's the &lt;code&gt;vi&lt;/code&gt; of scripting—ugly, ornery, and always there when you need it.&lt;/p&gt;

&lt;p&gt;To monitor this properly, we can't trust Certbot's output. Certbot lies. Not maliciously—it's just optimistic in a way that will ruin your weekend. We need to do what a user does: ask the web server to show us its papers.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Core Command
&lt;/h3&gt;

&lt;p&gt;We're going to use &lt;code&gt;openssl s_client&lt;/code&gt;. If you've ever debugged a TLS handshake manually, you know this tool is powerful and incredibly annoying—like a Swiss Army knife where half the blades are unlabeled. We pipe the handshake into &lt;code&gt;x509&lt;/code&gt; to extract the expiration date:&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="nb"&gt;timeout &lt;/span&gt;10 openssl s_client &lt;span class="nt"&gt;-servername&lt;/span&gt; example.com &lt;span class="nt"&gt;-connect&lt;/span&gt; example.com:443 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;echo |&lt;/code&gt; pipe:&lt;/strong&gt; Without input, &lt;code&gt;s_client&lt;/code&gt; waits for stdin (interactive mode). The echo closes the connection cleanly. Yes, you're literally telling openssl to shut up and get on with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;-servername&lt;/code&gt; flag:&lt;/strong&gt; Crucial. Without SNI, Nginx serves the default cert—probably the snake-oil one or whatever config it loaded first alphabetically. You'll get a heart attack for no reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;timeout&lt;/code&gt;:&lt;/strong&gt; Without it, a hung remote host blocks your script indefinitely. The loop stops, the rest of your domains don't get checked. How do I know this? Pain.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. The Monitoring Script (&lt;code&gt;ssl_check.sh&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;I'm an Emacs guy (Evil mode, because &lt;code&gt;hjkl&lt;/code&gt; is muscle memory I refuse to unlearn), but paste this into whatever editor brings you joy. Even nano. I won't judge. (I will judge a little.)&lt;/p&gt;

&lt;p&gt;This script iterates through your domains, checks expiration against the live server, and screams at you if you have less than a week left.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="c"&gt;# Fail on undefined variables and pipe failures.&lt;/span&gt;
&lt;span class="c"&gt;# If you aren't using this in bash scripts, start now. Future you will thank present you.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

&lt;span class="c"&gt;# Configuration&lt;/span&gt;
&lt;span class="nv"&gt;DOMAINS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt; &lt;span class="s2"&gt;"app.example.com"&lt;/span&gt; &lt;span class="s2"&gt;"api.example.com"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://hooks.slack.com/services/YOUR/WEBHOOK/URL"&lt;/span&gt;
&lt;span class="nv"&gt;DAYS_THRESHOLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;7
&lt;span class="nv"&gt;TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10

send_alert&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="c"&gt;# Silence curl output so cron doesn't spam you with "100% downloaded" messages&lt;/span&gt;
    curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;DOMAIN &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DOMAINS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="c"&gt;# Grab the expiration date from the live cert&lt;/span&gt;
    &lt;span class="nv"&gt;EXP_DATE&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="nb"&gt;timeout&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; openssl s_client &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:443 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
        | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
        | &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="c"&gt;# Validate we got something that looks like a date.&lt;/span&gt;
    &lt;span class="c"&gt;# Note: 'date -d' is GNU specific. If you are on BSD/macOS, use 'date -j -f'.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXP_DATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXP_DATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PANIC: Could not retrieve valid certificate for &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        send_alert &lt;span class="s2"&gt;"SSL PANIC: Cannot retrieve certificate for &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt;. Check immediately."&lt;/span&gt;
        &lt;span class="k"&gt;continue
    fi&lt;/span&gt;

    &lt;span class="c"&gt;# Convert to epoch for math&lt;/span&gt;
    &lt;span class="nv"&gt;EXP_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXP_DATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;NOW_EPOCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;DAYS_REMAINING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;EXP_EPOCH &lt;span class="o"&gt;-&lt;/span&gt; NOW_EPOCH&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

    &lt;span class="nb"&gt;echo&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;'+%Y-%m-%d %H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="nv"&gt;$DAYS_REMAINING&lt;/span&gt;&lt;span class="s2"&gt; days remaining"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DAYS_REMAINING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-le&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DAYS_THRESHOLD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;send_alert &lt;span class="s2"&gt;"SSL ALERT: &lt;/span&gt;&lt;span class="nv"&gt;$DOMAIN&lt;/span&gt;&lt;span class="s2"&gt; expires in &lt;/span&gt;&lt;span class="nv"&gt;$DAYS_REMAINING&lt;/span&gt;&lt;span class="s2"&gt; days. Fix it now."&lt;/span&gt;
    &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. The Cron Job
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;chmod +x&lt;/code&gt; that bad boy and throw it in cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 6 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /usr/local/bin/ssl_check.sh &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/ssl_check.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Runs at 6 AM daily. Early enough to fix things before the US wakes up, late enough that you're not debugging at 3 AM. Adjust to taste and timezone guilt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Script Is Going to Burn You (The "Turn")
&lt;/h2&gt;

&lt;p&gt;I've written variations of that script for 25 years. It's better than nothing, and it proves you respect the fundamentals. But honestly? It's got holes you could drive a mass production Kubernetes deployment through.&lt;/p&gt;

&lt;p&gt;Here's why relying solely on this gives you a false sense of security:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The "Localhost" Lie
&lt;/h3&gt;

&lt;p&gt;The script runs &lt;em&gt;on&lt;/em&gt; your server. If you have split-horizon DNS, internal routing, or a load balancer in front, the script might see a valid internal cert while the outside world sees a firewall block or a timeout. Your monitoring says green. Your customers see red. Your phone rings.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Silent Cron Failure
&lt;/h3&gt;

&lt;p&gt;Who monitors the monitor?&lt;/p&gt;

&lt;p&gt;I once had a cron daemon die after a botched OS upgrade. Didn't notice for three weeks—until four certs expired on the same day. The script was perfect. It just wasn't running.&lt;/p&gt;

&lt;p&gt;And unless you configure Postfix (and who actually does that anymore?), cron errors go to &lt;code&gt;/var/mail/root&lt;/code&gt;. Nobody reads &lt;code&gt;/var/mail/root&lt;/code&gt;. That file is where error messages go to die.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The Intermediate Chain Problem
&lt;/h3&gt;

&lt;p&gt;The script as written only checks the leaf certificate. &lt;code&gt;openssl s_client&lt;/code&gt; receives the full chain, but &lt;code&gt;openssl x509&lt;/code&gt; typically only parses the first PEM block it sees.&lt;/p&gt;

&lt;p&gt;If your intermediate cert expires—or Nginx isn't sending the chain at all—older clients (especially Android, bless its fragmented heart) will reject the connection. Your script says "30 days remaining." Half your mobile users see a warning page. Support tickets pile up. "Works on my machine" becomes your epitaph.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The Wrong Cert Problem
&lt;/h3&gt;

&lt;p&gt;Here's a fun one: the cert is valid. Expiration looks great. It's just not &lt;em&gt;your&lt;/em&gt; cert.&lt;/p&gt;

&lt;p&gt;Maybe someone renewed with a different CA. Maybe the wildcard got swapped for a single-domain cert during a late-night deploy. Maybe a junior admin grabbed the staging cert by mistake. (No shade—we've all been that junior admin.)&lt;/p&gt;

&lt;p&gt;The expiration date looks fine. The site is broken anyway. Your script sees nothing wrong because technically nothing &lt;em&gt;is&lt;/em&gt; wrong—except everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Maintenance Debt
&lt;/h3&gt;

&lt;p&gt;Every time you spin up a new container, subdomain, or microservice, you have to SSH in and update the &lt;code&gt;DOMAINS&lt;/code&gt; array. Forget once, and you're not monitoring it.&lt;/p&gt;

&lt;p&gt;"I'll add it to the script later," you say, mass producing a future 2 AM incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solution: External Monitoring
&lt;/h2&gt;

&lt;p&gt;I got tired of maintaining bespoke Bash scripts across different environments. I wanted something that checked my sites from the &lt;em&gt;outside&lt;/em&gt;—like a real user—and handled the edge cases automatically.&lt;/p&gt;

&lt;p&gt;That's why I built &lt;strong&gt;&lt;a href="https://sslguard.net" rel="noopener noreferrer"&gt;SSLGuard&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's the tool I wish I had back when I was manually verifying cert chains on Sun boxes and writing Perl scripts I'm not proud of.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checks from the outside:&lt;/strong&gt; No more "works on my machine" false positives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validates the full chain:&lt;/strong&gt; If the intermediate is busted, you'll know before your users do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerts to Slack, Teams, email:&lt;/strong&gt; You get notified &lt;em&gt;before&lt;/em&gt; the cert expires, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero maintenance:&lt;/strong&gt; No scripts to update, no cron to babysit, no &lt;code&gt;/var/mail/root&lt;/code&gt; to ignore.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have zero budget, steal the Bash script above. It's free, and it's better than nothing.&lt;/p&gt;

&lt;p&gt;But if you value your time—and your sleep—&lt;strong&gt;&lt;a href="https://sslguard.net" rel="noopener noreferrer"&gt;give SSLGuard a try&lt;/a&gt;&lt;/strong&gt;. Takes 30 seconds to set up. You'll never have that Monday morning panic again.&lt;/p&gt;

&lt;p&gt;Your future self will thank you. Possibly with coffee.&lt;/p&gt;

</description>
      <category>ssl</category>
      <category>nginx</category>
      <category>automation</category>
      <category>letsencrypt</category>
    </item>
  </channel>
</rss>
