<?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: Guatu</title>
    <description>The latest articles on DEV Community by Guatu (@futhgar).</description>
    <link>https://dev.to/futhgar</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%2F3847021%2F5aa46faa-d8e6-4023-ad78-5a335f875d69.png</url>
      <title>DEV Community: Guatu</title>
      <link>https://dev.to/futhgar</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/futhgar"/>
    <language>en</language>
    <item>
      <title>Tailscale Exit Node Logic: Why Auto-Select Fails for Security and How to Fix It</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 12 Oct 2026 16:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/tailscale-exit-node-logic-why-auto-select-fails-for-security-and-how-to-fix-it-4da2</link>
      <guid>https://dev.to/futhgar/tailscale-exit-node-logic-why-auto-select-fails-for-security-and-how-to-fix-it-4da2</guid>
      <description>&lt;p&gt;The Tailscale tray icon says "Connected," and that tells you nothing about where your web traffic is going. If no exit node is active, Tailscale only carries traffic to your tailnet. Everything else (browsing, DNS lookups to non-tailnet resolvers, the background update checks your OS runs) goes out through whatever Wi-Fi you're on, just as it would without Tailscale. On a hotel or airport network, that's the traffic you wanted protected.&lt;/p&gt;

&lt;p&gt;So you turn on an exit node. That leaves one question: should Tailscale pick the node for you (&lt;code&gt;auto&lt;/code&gt;), or should you pin one and enforce it? Tailscale's docs present auto-select as the convenient default, and on a desktop that never moves it's usually fine. On a laptop that roams between networks you don't control, auto-select is solving a different problem from yours.&lt;/p&gt;

&lt;p&gt;This post compares the two approaches, explains why auto-select falls short as a security control, and shows the setup I'd use instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you'd have to choose
&lt;/h2&gt;

&lt;p&gt;The decision shows up as soon as you have more than one exit node. Common setups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A home exit node running on a small VM or LXC (I covered the unprivileged-container version in &lt;a href="https://guatulabs.dev/posts/tailscale-kernel-tun-in-unprivileged-lxc-direct-ssh-without-userspace-networking/" rel="noopener noreferrer"&gt;Tailscale Kernel TUN in Unprivileged LXC&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;A cloud VPS exit node for when home upload bandwidth is bad&lt;/li&gt;
&lt;li&gt;Mullvad exit nodes through the Tailscale add-on&lt;/li&gt;
&lt;li&gt;Exit nodes shared with you from someone else's tailnet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With one exit node, there's nothing to decide. With several, the client has to pick one. You can let it pick (&lt;code&gt;--exit-node=auto:any&lt;/code&gt;) or you can pick yourself (&lt;code&gt;--exit-node=exit-home&lt;/code&gt;). The two options look like a convenience question. They're really a question about what you're optimizing for.&lt;/p&gt;

&lt;p&gt;Underneath that sits a second decision most people never make on purpose: what should happen when the chosen exit node is unreachable? Should traffic stop (fail closed), or fall back to the local network (fail open)? The two approaches answer this differently, and the answer matters more than which node gets picked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option A: Auto-select (&lt;code&gt;auto:any&lt;/code&gt;)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What it is
&lt;/h3&gt;

&lt;p&gt;Recent Tailscale clients can choose the exit node themselves. You can ask what the client would pick:&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;# Shows the exit node Tailscale would suggest right now&lt;/span&gt;
tailscale exitnode suggest

&lt;span class="c"&gt;# Tell the client to keep choosing automatically&lt;/span&gt;
tailscale &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--exit-node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;auto:any
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The suggestion is based on reachability and latency (and region, for Mullvad-style location-based nodes). As conditions change, the client can switch to a different node. Admins can also push this through MDM/system policy (&lt;code&gt;ExitNodeID&lt;/code&gt; set to &lt;code&gt;auto:any&lt;/code&gt;) so the user can't turn it off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No hostname to keep current.&lt;/strong&gt; Add or retire an exit node and clients adjust on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Good latency.&lt;/strong&gt; If you travel between regions and have exit nodes in several of them, auto picks the close one. That's useful if you mostly care about speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survives a dead node.&lt;/strong&gt; When one exit node goes offline, auto moves to another one rather than leaving you stuck on a dead route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fleet-friendly.&lt;/strong&gt; For an org with dozens of regional exit nodes and centrally managed laptops, auto plus MDM policy takes almost no ongoing work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Weaknesses
&lt;/h3&gt;

&lt;p&gt;The issue is what auto doesn't know, and there are two gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't know which exit nodes you trust.&lt;/strong&gt; "Any" means any exit node your ACLs let this device use. If a friend's shared node, a test VPS you forgot about, or a Mullvad node in a country you'd rather not appear from is eligible, it's in the pool. Auto won't treat your home node, which you patch and control, differently from a node someone else runs. Every eligible exit node can see your plaintext egress traffic after it leaves the WireGuard tunnel. That makes "who runs this box" a security question, and auto answers it by measuring latency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't know anything about your local network.&lt;/strong&gt; Auto has no idea whether you're on the home LAN you trust or on airport Wi-Fi. Tailscale doesn't have a built-in "trusted SSID" setting. Whether the exit node gets enabled at all is up to you or your MDM policy. Auto only decides which node to use after something else has decided to use one.&lt;/p&gt;

&lt;p&gt;There's a smaller operational point too. The egress IP can change during a session when auto moves you to another node. Sites that tie sessions to IPs (banking, some SSO providers, anything doing geo-velocity checks) may log you out or show a challenge page. That doesn't make you less secure, but it's annoying enough that people turn exit nodes off, which does.&lt;/p&gt;

&lt;h3&gt;
  
  
  When it shines
&lt;/h3&gt;

&lt;p&gt;Auto-select works well when every eligible exit node belongs to one operator you trust, when ACLs keep the pool tight, and when policy forces the exit node on. That describes a well-run corporate fleet. It doesn't describe a homelab tailnet where your shared-node experiments live next to your actual egress box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option B: Pinned exit node, enforced by the OS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What it is
&lt;/h3&gt;

&lt;p&gt;You choose a specific exit node by its MagicDNS name and make the operating system enforce it, so it doesn't depend on you remembering to click the toggle. The naive version looks like 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;# The naive pin: works until it doesn't&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up &lt;span class="nt"&gt;--exit-node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100.64.0.10 &lt;span class="nt"&gt;--exit-node-allow-lan-access&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two problems with this. Pinning by tailnet IP is fragile. If you rebuild the exit node and it gets a new address, every client stays pinned to an address that no longer exists. Use the hostname instead (&lt;code&gt;--exit-node=exit-home&lt;/code&gt;). Also, &lt;code&gt;tailscale up&lt;/code&gt; with flags will complain if you leave out settings you set earlier. &lt;code&gt;tailscale set&lt;/code&gt; changes only what you pass, which makes it the right tool for automation.&lt;/p&gt;

&lt;p&gt;The second problem is more interesting. A pinned exit node fails closed. If &lt;code&gt;exit-home&lt;/code&gt; is unreachable (home ISP outage, the VM is rebooting, a NAT traversal problem after you roam to a new access point), the client keeps sending your default route into a tunnel that goes nowhere. In practice that looks like half-loaded pages, DNS timeouts, and a browser stuck on "Resolving host…" forever. From a security standpoint, that's the correct behavior. It's also exactly what makes people turn the exit node off at the airport gate and forget to turn it back on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the pin hold up: a NetworkManager dispatcher
&lt;/h3&gt;

&lt;p&gt;The fix is to stop treating the exit node as a setting you flip by hand and treat it as policy that runs on network events. NetworkManager runs every executable in &lt;code&gt;/etc/NetworkManager/dispatcher.d/&lt;/code&gt; with the interface and action as arguments. That's enough to say: "whenever a Wi-Fi connection comes up, enforce the exit node, preferring my own node and falling back to a second trusted 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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# /etc/NetworkManager/dispatcher.d/50-wifi-safe&lt;/span&gt;
&lt;span class="c"&gt;# Enforce a trusted exit node whenever Wi-Fi comes up. Idempotent.&lt;/span&gt;
&lt;span class="nv"&gt;IFACE&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="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;ACTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PREFERRED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"exit-home"&lt;/span&gt;     &lt;span class="c"&gt;# MagicDNS names, not IPs&lt;/span&gt;
&lt;span class="nv"&gt;FALLBACK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"exit-vps"&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACTION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in &lt;/span&gt;up|connectivity-change&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; GENERAL.TYPE device show &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"wifi"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="c"&gt;# Give tailscaled a moment after roaming before probing peers&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;_ &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;tailscale status &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;2
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nv"&gt;pick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;node &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PREFERRED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FALLBACK&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
  if &lt;/span&gt;tailscale ping &lt;span class="nt"&gt;--c&lt;/span&gt; 1 &lt;span class="nt"&gt;--timeout&lt;/span&gt; 3s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;pick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;fi
done&lt;/span&gt;

&lt;span class="c"&gt;# No trusted node reachable: still pin the preferred one. Fail closed, never open.&lt;/span&gt;
tailscale &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--exit-node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pick&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$PREFERRED&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--exit-node-allow-lan-access&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
&lt;/span&gt;logger &lt;span class="nt"&gt;-t&lt;/span&gt; wifi-safe &lt;span class="s2"&gt;"iface=&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt; action=&lt;/span&gt;&lt;span class="nv"&gt;$ACTION&lt;/span&gt;&lt;span class="s2"&gt; exit-node=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pick&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$PREFERRED&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/NetworkManager/dispatcher.d/50-wifi-safe
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /etc/NetworkManager/dispatcher.d/50-wifi-safe   &lt;span class="c"&gt;# NM skips files that aren't root-owned&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details here are deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No SSID allowlist.&lt;/strong&gt; The script treats every Wi-Fi network as hostile, including your home network. That's the "trust the tailnet, not the network" model from &lt;a href="https://guatulabs.dev/posts/trust-the-tailnet-not-the-network-automating-wi-fi-hardening-with-networkmanager-dispatchers/" rel="noopener noreferrer"&gt;the earlier post on dispatcher hardening&lt;/a&gt;. Allowlists rot: the coffee shop renames its SSID, someone sets up an evil twin of your home network name, and suddenly you're trusting something you shouldn't. If you really want home traffic to skip the exit node, match on something harder to spoof than an SSID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fallback list is short and curated.&lt;/strong&gt; That's the actual difference from &lt;code&gt;auto:any&lt;/code&gt;. You still get failover, but only among nodes you've decided to trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total failure still fails closed.&lt;/strong&gt; If neither node responds, the script pins the preferred one anyway. You lose connectivity instead of quietly sending traffic in the clear. The &lt;code&gt;logger&lt;/code&gt; line means you can check &lt;code&gt;journalctl -t wifi-safe&lt;/code&gt; and see why.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Locking down the local side
&lt;/h3&gt;

&lt;p&gt;An exit node protects egress. It does nothing about other devices on the hotel network probing your laptop. Two pieces cover that, and both have persistence gotchas I've written about separately.&lt;/p&gt;

&lt;p&gt;Bind Wi-Fi connections to the restrictive firewall zone through NetworkManager, so the binding survives reboots (runtime-only &lt;code&gt;firewall-cmd&lt;/code&gt; changes vanish, as covered in &lt;a href="https://guatulabs.dev/posts/firewall-zone-persistence-why-firewalld-runtime-changes-vanish-and-how-to-pin-them/" rel="noopener noreferrer"&gt;Firewall Zone Persistence&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Every saved Wi-Fi profile goes to the public zone&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;c &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; NAME,TYPE connection show | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'$2=="802-11-wireless"{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection modify &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; connection.zone public
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# tailscale0 isn't NM-managed, so assign it permanently via firewalld&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;trusted &lt;span class="nt"&gt;--add-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tailscale0
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The &lt;code&gt;for&lt;/code&gt; loop splits on whitespace, so SSIDs with spaces need a &lt;code&gt;while read&lt;/code&gt; loop instead. Most of mine don't have spaces, but yours might.)&lt;/p&gt;

&lt;p&gt;Then turn off the name-resolution protocols that answer to anyone on the LAN. Use a drop-in rather than editing &lt;code&gt;resolved.conf&lt;/code&gt; directly. On recent Fedora the vendor defaults live elsewhere, and editing the main file can shadow them in ways that are hard to notice (&lt;a href="https://guatulabs.dev/posts/systemd-config-merges-avoiding-silent-shadowing-with-drop-ins-on-fedora-43/" rel="noopener noreferrer"&gt;Systemd Config Merges&lt;/a&gt; has the details):&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="c"&gt;# /etc/systemd/resolved.conf.d/50-untrusted-lan.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Resolve]&lt;/span&gt;
&lt;span class="py"&gt;LLMNR&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;span class="py"&gt;MulticastDNS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-resolved
resolvectl status | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'LLMNR|mDNS'&lt;/span&gt;   &lt;span class="c"&gt;# both should read "no"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Diagnosing "half-loaded pages"
&lt;/h3&gt;

&lt;p&gt;When the pinned approach breaks, the symptom is always the same: some pages load from cache, others hang, and DNS looks flaky. The cause can be on either end, and the fix depends on which one it is.&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;# Is the local daemon healthy? Look for health warnings at the bottom.&lt;/span&gt;
tailscale status

&lt;span class="c"&gt;# Which exit node is set, and does the client think it's online?&lt;/span&gt;
tailscale status &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.ExitNodeStatus'&lt;/span&gt;

&lt;span class="c"&gt;# Can we actually reach it, and via DERP or direct?&lt;/span&gt;
tailscale ping exit-home

&lt;span class="c"&gt;# Is the problem DERP/UDP reachability from this network?&lt;/span&gt;
tailscale netcheck
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How to read it: if &lt;code&gt;tailscale status&lt;/code&gt; shows health warnings about DNS forwarding failing or no DERP connection, the problem is local. The network you're on is blocking UDP, a captive portal hasn't been accepted, or tailscaled is wedged. If status looks clean but &lt;code&gt;ExitNodeStatus.Online&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt; or &lt;code&gt;tailscale ping&lt;/code&gt; times out, the exit node itself is down, and that's what the fallback in the dispatcher exists for.&lt;/p&gt;

&lt;p&gt;Captive portals deserve a mention. Until you accept the portal, nothing gets through, including the tunnel to your exit node. Most portals intercept HTTP, which never reaches them because your traffic is pointed at a dead route. The practical fix is to temporarily clear the exit node with &lt;code&gt;tailscale set --exit-node=&lt;/code&gt;, accept the portal, and then reconnect Wi-Fi so the dispatcher puts the pin back. Tailscale's captive portal detection helps here on recent clients, but I wouldn't count on it at every hotel.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weaknesses of Option B
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;More moving parts: a script, file permissions, firewall zones, a resolved drop-in.&lt;/li&gt;
&lt;li&gt;Linux/NetworkManager-specific. macOS and Windows need different mechanisms (MDM policy is the realistic choice there).&lt;/li&gt;
&lt;li&gt;Captive portals need a manual step.&lt;/li&gt;
&lt;li&gt;If both trusted nodes go down, you're offline until one comes back. That's the point of the design, but it's still a cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Decision framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Auto-select (&lt;code&gt;auto:any&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Pinned + dispatcher&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who chooses the egress operator&lt;/td&gt;
&lt;td&gt;Latency and reachability&lt;/td&gt;
&lt;td&gt;You, from a short trusted list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Awareness of local network trust&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Treats every Wi-Fi as hostile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behavior when the chosen node dies&lt;/td&gt;
&lt;td&gt;Moves to any other eligible node&lt;/td&gt;
&lt;td&gt;Moves to the next trusted node, else fails closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Egress IP stability&lt;/td&gt;
&lt;td&gt;Can change mid-session&lt;/td&gt;
&lt;td&gt;Stable unless failover fires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup effort&lt;/td&gt;
&lt;td&gt;One command or MDM key&lt;/td&gt;
&lt;td&gt;Script, permissions, firewall, resolved drop-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ongoing maintenance&lt;/td&gt;
&lt;td&gt;Near zero&lt;/td&gt;
&lt;td&gt;Update the node list when you rebuild exit nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform coverage&lt;/td&gt;
&lt;td&gt;All Tailscale clients&lt;/td&gt;
&lt;td&gt;Linux with NetworkManager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Managed fleet, single-operator exit pool&lt;/td&gt;
&lt;td&gt;Personal laptops on a mixed-trust tailnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main risk&lt;/td&gt;
&lt;td&gt;Egress through a node you didn't mean to trust&lt;/td&gt;
&lt;td&gt;Offline when every trusted node is down&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  My pick and why
&lt;/h2&gt;

&lt;p&gt;On a roaming Linux laptop, I'd use the pinned exit node with dispatcher enforcement every time.&lt;/p&gt;

&lt;p&gt;My reasoning: the exit node operator sees your traffic after decryption. That makes the choice of exit node a trust decision, and trust decisions shouldn't come from a latency measurement. Auto-select is a good availability feature, and it was built as one. Once your tailnet holds nodes with different owners (shared nodes, experiments, third-party providers), "the fastest eligible node" and "a node I trust with my traffic" stop meaning the same thing, and auto has no setting that tells them apart.&lt;/p&gt;

&lt;p&gt;The pinned approach without automation has a real failure mode, though. It fails closed, which means it's annoying, which means people disable it. The dispatcher takes care of that. It reapplies the pin on every network change, handles failover inside a list you control, and only cuts connectivity when no trusted option exists. You get most of auto's convenience and keep the trust decision for yourself.&lt;/p&gt;

&lt;p&gt;If you run a fleet where every exit node is yours and ACLs keep the pool clean, auto plus MDM enforcement is a reasonable choice. I'd still audit the eligible pool regularly with &lt;code&gt;tailscale exit-node list&lt;/code&gt; to check that it contains only what you expect.&lt;/p&gt;

&lt;p&gt;Things I'd watch out for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin by MagicDNS name, never by tailnet IP.&lt;/strong&gt; Rebuilt nodes get new addresses, and clients pinned to the old one fail with no useful error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;tailscale set&lt;/code&gt; in scripts, not &lt;code&gt;tailscale up&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;up&lt;/code&gt; wants the full flag set restated and will refuse or reset settings you didn't mention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make firewall and resolver changes permanent.&lt;/strong&gt; A hardening setup that disappears after a reboot is worse than none, because you believe it's still there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Connected" isn't "protected."&lt;/strong&gt; Check &lt;code&gt;ExitNodeStatus&lt;/code&gt; and an external IP check (&lt;code&gt;curl -s https://ifconfig.me&lt;/code&gt;) before trusting a hostile network. The tray icon only reports whether the tunnel is up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building the exit node side of this, &lt;a href="https://guatulabs.dev/posts/tailscale-subnet-router-remote-access-without-traditional-vpn/" rel="noopener noreferrer"&gt;Tailscale Subnet Routers&lt;/a&gt; covers the router configuration that pairs with it. If you want help designing this kind of zero-trust egress for a team instead of a single laptop, that's the kind of &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;infrastructure work I do&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tailscale</category>
      <category>exitnodes</category>
      <category>networking</category>
      <category>security</category>
    </item>
    <item>
      <title>Systemd Config Merges: Avoiding Silent Shadowing with Drop-ins on Fedora 43</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 09 Oct 2026 16:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/systemd-config-merges-avoiding-silent-shadowing-with-drop-ins-on-fedora-43-16ld</link>
      <guid>https://dev.to/futhgar/systemd-config-merges-avoiding-silent-shadowing-with-drop-ins-on-fedora-43-16ld</guid>
      <description>&lt;p&gt;You edit a unit file, run &lt;code&gt;systemctl daemon-reload&lt;/code&gt;, restart the service, and nothing changes. Or you add one &lt;code&gt;Environment=&lt;/code&gt; line in a drop-in, and a variable you never touched ends up with a different value. In neither case does systemd raise an error. It merges the files it finds using its own rules, then runs the result.&lt;/p&gt;

&lt;p&gt;This is a gotcha post about how those merges work. A drop-in can shadow a unit file or another drop-in without any warning, and list-type settings cause the worst of it. My examples come from Fedora 43, but the merge logic has been stable for years, so all of this applies to any modern systemd distro.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;Most versions of this bug look like one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You edit &lt;code&gt;/usr/lib/systemd/system/foo.service&lt;/code&gt; (or &lt;code&gt;/etc/systemd/system/foo.service&lt;/code&gt;) and the change has no effect.&lt;/li&gt;
&lt;li&gt;You add a drop-in to "just add one thing" to an &lt;code&gt;ExecStartPre=&lt;/code&gt; chain or an environment, and something else in the service breaks.&lt;/li&gt;
&lt;li&gt;You change a variable in an env file, restart, and the process still sees the old value.&lt;/li&gt;
&lt;li&gt;You add a drop-in with a new &lt;code&gt;ExecStart=&lt;/code&gt; and the unit refuses to start with:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;foo.service: Service has more than one ExecStart= setting, which is only allowed for Type=oneshot services. Refusing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is the lucky case, because at least systemd complains. The others fail silently. The service starts, reports &lt;code&gt;active (running)&lt;/code&gt;, and does something slightly different from what you think you configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Expected
&lt;/h2&gt;

&lt;p&gt;A unit feels like a file. One file, one config. Drop-ins feel like patches: you put &lt;code&gt;override.conf&lt;/code&gt; next to the service, and its lines "win." I worked from that mental model for a long time, and I see it constantly in forum answers and copy-pasted setup scripts.&lt;/p&gt;

&lt;p&gt;Under that model, editing the main unit file should change behavior. Adding a line to a drop-in should add exactly that line and nothing else. Changing an env file should change the environment. All three assumptions are wrong in specific, predictable ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happens
&lt;/h2&gt;

&lt;p&gt;systemd builds every unit from a stack of files spread across several directories. It then applies merge rules that depend on the type of each setting. Four separate mechanisms interact here, and each one can hide a change.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Whole-file shadowing by directory priority
&lt;/h3&gt;

&lt;p&gt;Unit files are searched in a fixed order. For system units, these are the directories that matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Who owns it&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/etc/systemd/system/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;You (admin)&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/run/systemd/system/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runtime, generators&lt;/td&gt;
&lt;td&gt;Middle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/systemd/system/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Packages (RPM)&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If &lt;code&gt;foo.service&lt;/code&gt; exists in both &lt;code&gt;/etc&lt;/code&gt; and &lt;code&gt;/usr/lib&lt;/code&gt;, the &lt;code&gt;/etc&lt;/code&gt; copy wins &lt;strong&gt;entirely&lt;/strong&gt;. systemd doesn't merge the &lt;code&gt;/usr/lib&lt;/code&gt; file. It ignores it. That's why &lt;code&gt;systemctl edit --full foo.service&lt;/code&gt; is a trap: it copies the vendor unit into &lt;code&gt;/etc&lt;/code&gt;, and from then on every package update to the vendor unit is invisible. Months later the RPM ships a fix to &lt;code&gt;ExecStart=&lt;/code&gt;, &lt;code&gt;dnf&lt;/code&gt; installs it cleanly, and your service keeps running the frozen copy.&lt;/p&gt;

&lt;p&gt;It cuts the other way too. You edit the file under &lt;code&gt;/usr/lib&lt;/code&gt; directly, but a full copy is sitting in &lt;code&gt;/etc&lt;/code&gt; from some earlier experiment. Your edit does nothing, and the next package update will overwrite it anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Drop-in shadowing by filename
&lt;/h3&gt;

&lt;p&gt;Drop-ins live in &lt;code&gt;foo.service.d/&lt;/code&gt; directories under each of those same paths. systemd collects every &lt;code&gt;*.conf&lt;/code&gt; file from all of them, sorts them by &lt;strong&gt;filename&lt;/strong&gt; (not by directory), and applies them in that order. For scalar settings, later files win.&lt;/p&gt;

&lt;p&gt;Here's the catch. If two drop-ins have the &lt;strong&gt;same filename&lt;/strong&gt; in different directories, systemd only uses the higher-priority one. A &lt;code&gt;/etc/systemd/system/foo.service.d/override.conf&lt;/code&gt; completely masks a &lt;code&gt;/usr/lib/systemd/system/foo.service.d/override.conf&lt;/code&gt;. That vendor drop-in may have set a timeout or a hardening option you never knew about, and now it's gone.&lt;/p&gt;

&lt;p&gt;Fedora adds another layer because it ships top-level type drop-ins. A directory named &lt;code&gt;service.d/&lt;/code&gt; applies to &lt;strong&gt;every&lt;/strong&gt; service on the box, and Fedora uses one (&lt;code&gt;10-timeout-abort.conf&lt;/code&gt;) to change what happens when a service hits its stop timeout. Prefix drop-ins work too: &lt;code&gt;foo-.service.d/&lt;/code&gt; applies to &lt;code&gt;foo-bar.service&lt;/code&gt;, &lt;code&gt;foo-baz.service&lt;/code&gt;, and so on. As a result, settings in your unit can come from files whose names never mention it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. List settings append, scalars replace
&lt;/h3&gt;

&lt;p&gt;This one causes the most damage. The behavior changes from setting to setting, and nothing in the unit file syntax tells you which kind you're dealing with.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scalar settings&lt;/strong&gt; (&lt;code&gt;User=&lt;/code&gt;, &lt;code&gt;TimeoutStartSec=&lt;/code&gt;, &lt;code&gt;Restart=&lt;/code&gt;, &lt;code&gt;WorkingDirectory=&lt;/code&gt;): last assignment wins. Drop-ins behave the way you'd expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List settings&lt;/strong&gt; (&lt;code&gt;Environment=&lt;/code&gt;, &lt;code&gt;EnvironmentFile=&lt;/code&gt;, &lt;code&gt;ExecStartPre=&lt;/code&gt;, &lt;code&gt;ExecStartPost=&lt;/code&gt;, &lt;code&gt;ExecStart=&lt;/code&gt; for oneshot, &lt;code&gt;ReadWritePaths=&lt;/code&gt;, and many more): each assignment &lt;strong&gt;appends&lt;/strong&gt; to the list. An &lt;strong&gt;empty assignment&lt;/strong&gt; (&lt;code&gt;ExecStartPre=&lt;/code&gt; with nothing after it) &lt;strong&gt;resets&lt;/strong&gt; the list to empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives you two opposite failure modes from the same drop-in.&lt;/p&gt;

&lt;p&gt;First, accidental accumulation. You want to replace a pre-start step, so you write a new &lt;code&gt;ExecStartPre=&lt;/code&gt; in a drop-in. systemd appends it. Now both the old and the new step run in order, and if the old one fails, the unit never starts.&lt;/p&gt;

&lt;p&gt;Second, accidental truncation. Once people learn about the empty-reset trick, some start every drop-in with an empty assignment "to be safe." Every entry that came before gets wiped, including entries from vendor files they never read.&lt;/p&gt;

&lt;p&gt;Truncation is the sneakier of the two. Here's the vendor unit:&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="c"&gt;# /usr/lib/systemd/system/agent-worker.service
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;simple&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/libexec/agent-worker/check-config&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/libexec/agent-worker/clean-sockets&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/agent-worker --serve&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's a drop-in written to "add one migration step":&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="c"&gt;# /etc/systemd/system/agent-worker.service.d/override.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="c"&gt;# Reset "just in case" -- this deletes check-config and clean-sockets
&lt;/span&gt;&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/libexec/agent-worker/migrate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;daemon-reload&lt;/code&gt;, the merged unit runs only &lt;code&gt;migrate&lt;/code&gt; before start. The config check is gone, and so is the socket cleanup. The service starts fine on the first boot, and on the next unclean restart it trips over a stale socket that &lt;code&gt;clean-sockets&lt;/code&gt; used to remove. When you go looking, the drop-in looks innocent and the vendor file looks correct. Neither file shows the problem on its own. You only see it in the merged result.&lt;/p&gt;

&lt;p&gt;I see the same shape in AI agent setups, which is part of why I'm writing this up. Many agent runners take their allowed-tool or safe-binary list from an environment variable or a list setting in the unit. A drop-in meant to add one tool resets the list, and the agent quietly loses every tool except the new one. If you've read &lt;a href="https://guatulabs.dev/posts/building-agent-skills-a-pattern-for-discoverable-capabilities/" rel="noopener noreferrer"&gt;how I structure agent skills&lt;/a&gt;, this is the systemd version of a registry losing its entries: nothing crashes, and the agent just reports that it "can't" do things it could do yesterday.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Environment precedence across sources
&lt;/h3&gt;

&lt;p&gt;Environment variables add their own ordering. Variables loaded from &lt;code&gt;EnvironmentFile=&lt;/code&gt; &lt;strong&gt;override&lt;/strong&gt; variables set with &lt;code&gt;Environment=&lt;/code&gt;, no matter which file declared them. Multiple env files are read in the order they're listed, and later files win.&lt;/p&gt;

&lt;p&gt;So if the main unit sets &lt;code&gt;Environment=LOG_LEVEL=info&lt;/code&gt;, and some drop-in adds &lt;code&gt;EnvironmentFile=/etc/agent-worker/legacy.env&lt;/code&gt; containing &lt;code&gt;LOG_LEVEL=debug&lt;/code&gt;, your process runs at debug. A drop-in that sets &lt;code&gt;Environment=LOG_LEVEL=warn&lt;/code&gt; won't change that, because env files take precedence. There's a timing difference to keep in mind as well. &lt;code&gt;Environment=&lt;/code&gt; lines are part of the unit definition and need a &lt;code&gt;daemon-reload&lt;/code&gt;. Env files are read from disk each time the service starts, so editing one only needs a restart. Mix up the two and you'll restart a service expecting a change it hasn't loaded yet.&lt;/p&gt;

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

&lt;p&gt;None of this is a bug. It's documented behavior, spread across &lt;code&gt;systemd.unit(5)&lt;/code&gt;, &lt;code&gt;systemd.service(5)&lt;/code&gt;, and &lt;code&gt;systemd.exec(5)&lt;/code&gt;. The fix is to stop reasoning about individual files and start checking the merged result.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inspect the merge, not the files
&lt;/h3&gt;

&lt;p&gt;Start with these commands. Each one answers a different question:&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;# Every file that contributes to the unit, in merge order&lt;/span&gt;
systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;agent-worker.service

&lt;span class="c"&gt;# Which file is the "main" unit, and which drop-ins were applied&lt;/span&gt;
systemctl show agent-worker.service &lt;span class="nt"&gt;-p&lt;/span&gt; FragmentPath &lt;span class="nt"&gt;-p&lt;/span&gt; DropInPaths

&lt;span class="c"&gt;# The final, merged value of a list setting&lt;/span&gt;
systemctl show agent-worker.service &lt;span class="nt"&gt;-p&lt;/span&gt; ExecStartPre

&lt;span class="c"&gt;# Final environment config (Environment= entries plus the env file list)&lt;/span&gt;
systemctl show agent-worker.service &lt;span class="nt"&gt;-p&lt;/span&gt; Environment &lt;span class="nt"&gt;-p&lt;/span&gt; EnvironmentFiles

&lt;span class="c"&gt;# System-wide: every overridden, extended, or masked unit&lt;/span&gt;
systemd-delta &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;overridden,extended
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemctl cat&lt;/code&gt; is where I start, because it prints file paths as comments above each section. A stray full copy in &lt;code&gt;/etc&lt;/code&gt; shows up right away. &lt;code&gt;systemctl show -p ExecStartPre&lt;/code&gt; is the definitive answer for list settings. It prints one &lt;code&gt;{ path=... ; argv[]=... }&lt;/code&gt; block per entry, so you can count the entries and confirm which ones survived the merge.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemd-delta&lt;/code&gt; doesn't get used enough. Run it after any distro upgrade. It lists every unit where &lt;code&gt;/etc&lt;/code&gt; overrides &lt;code&gt;/usr/lib&lt;/code&gt;, and that's exactly the list of services that have stopped receiving vendor fixes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemctl show -p Environment&lt;/code&gt; only tells you what the unit &lt;em&gt;declares&lt;/em&gt;. It doesn't expand env files. To see what the running process actually received, check one named variable and leave the rest alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; MainPID &lt;span class="nt"&gt;--value&lt;/span&gt; agent-worker.service&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# Check one known, non-secret variable. Don't dump the whole environ,&lt;/span&gt;
&lt;span class="c"&gt;# it usually contains credentials loaded from env files.&lt;/span&gt;
&lt;span class="nb"&gt;sudo tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &amp;lt; /proc/&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/environ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^LOG_LEVEL='&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Write drop-ins that state their intent
&lt;/h3&gt;

&lt;p&gt;Two rules cover most of the damage. Never use a reset unless you mean to replace the whole list. When you do mean it, restate the full list in the same file so a reader can see the final value without opening another file.&lt;/p&gt;

&lt;p&gt;Appending one step, the correct way:&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="c"&gt;# /etc/systemd/system/agent-worker.service.d/20-migrate.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="c"&gt;# Appends after the vendor's check-config and clean-sockets
&lt;/span&gt;&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/libexec/agent-worker/migrate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replacing the list on purpose:&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="c"&gt;# /etc/systemd/system/agent-worker.service.d/20-prestart.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="c"&gt;# Intentional reset: this file owns the complete pre-start chain
&lt;/span&gt;&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/libexec/agent-worker/check-config&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/libexec/agent-worker/clean-sockets&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/libexec/agent-worker/migrate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;ExecStart=&lt;/code&gt; on a non-oneshot service, the reset isn't optional. systemd only allows one entry, so the reset-then-set pattern is the only valid way to override it.&lt;/p&gt;

&lt;p&gt;Name your drop-ins. &lt;code&gt;override.conf&lt;/code&gt; is the default name &lt;code&gt;systemctl edit&lt;/code&gt; uses, which makes collisions with vendor drop-ins of the same name more likely. Use numbered, descriptive names instead, so the sort order is obvious and nothing gets masked by accident. On Fedora 43's systemd you can do that straight from &lt;code&gt;systemctl edit&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;sudo &lt;/span&gt;systemctl edit &lt;span class="nt"&gt;--drop-in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20-migrate agent-worker.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Prefer drop-ins over full copies, and revert the copies you have
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;systemd-delta&lt;/code&gt; shows a full unit override you didn't mean to keep, &lt;code&gt;systemctl revert&lt;/code&gt; removes the &lt;code&gt;/etc&lt;/code&gt; copy and every admin drop-in for that unit, so it falls back to the vendor version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl revert agent-worker.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="c"&gt;# Then re-apply only what you actually need, as a named drop-in&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl edit &lt;span class="nt"&gt;--drop-in&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20-migrate agent-worker.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before reverting, read the &lt;code&gt;/etc&lt;/code&gt; copy with &lt;code&gt;systemctl cat&lt;/code&gt; and copy any real customizations somewhere. Revert doesn't ask what's in there.&lt;/p&gt;

&lt;h3&gt;
  
  
  One environment file per service
&lt;/h3&gt;

&lt;p&gt;Environment shadowing goes away when there's one source of truth. My pattern is a single drop-in that resets the env file list and points at one managed file:&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="c"&gt;# /etc/systemd/system/agent-worker.service.d/10-env.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="c"&gt;# One source of truth; the leading "-" would make it optional, so omit it
&lt;/span&gt;&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/agent-worker/agent-worker.env&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the reset is deliberate and earns its place: it guarantees no vendor or legacy env file comes along for the ride. The &lt;code&gt;.env&lt;/code&gt; file is generated from version control or a secrets manager, not edited by hand in three different places. This is the same discipline I use for agent credentials in &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;two-tier service accounts&lt;/a&gt;. A secret should live in exactly one file, and the unit should name that file explicitly.&lt;/p&gt;

&lt;p&gt;User units deserve extra care. A &lt;code&gt;systemd --user&lt;/code&gt; service inherits from the user manager's environment, which gets assembled from &lt;code&gt;environment.d&lt;/code&gt; files, &lt;code&gt;systemctl --user import-environment&lt;/code&gt; calls, and whatever your login session pushed in. That's another layer of shadowing, one you can't see in the unit at all. Check it with &lt;code&gt;systemctl --user show-environment&lt;/code&gt;. For anything that matters, set the variables in the unit's env file instead of relying on inheritance. The execution-context surprises in &lt;a href="https://guatulabs.dev/posts/claude-headless-sandboxing-why-tmp-writes-fail-in-non-interactive-mode/" rel="noopener noreferrer"&gt;headless Claude sandboxing&lt;/a&gt; come from the same root problem: a process gets a different context than the shell you tested in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixing the config doesn't fix the service
&lt;/h3&gt;

&lt;p&gt;This step catches people even after they've fixed the merge. Correcting the drop-in fixes what systemd starts. It doesn't fix what the application remembers. Services that keep state between runs (session files, caches, agent conversation histories) can carry the effects of the broken config into the fixed one.&lt;/p&gt;

&lt;p&gt;Agents are the worst case. An agent that ran for a while with a truncated tool list has a history full of failed tool calls and notes like "this tool is unavailable." Restore the tools, restart the service, and the agent can still reload that history and keep avoiding tools that work fine now. The config is correct and the behavior isn't, because the state learned from the broken run.&lt;/p&gt;

&lt;p&gt;So my reset procedure has a step after the restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;svc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;agent-worker.service
&lt;span class="nv"&gt;state_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/agent-worker/sessions  &lt;span class="c"&gt;# example path&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="c"&gt;# Confirm the merge before trusting it&lt;/span&gt;
systemctl show &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$svc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ExecStartPre &lt;span class="nt"&gt;-p&lt;/span&gt; EnvironmentFiles
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl stop &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$svc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# Clear state that was learned under the broken config&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$state_dir&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-mindepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$svc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
systemctl status &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$svc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop before you clear state, not after. Otherwise the running process can write a fresh copy of the stale session back to disk during shutdown.&lt;/p&gt;

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

&lt;p&gt;You'll hit this in a few predictable situations: after a distro upgrade where vendor units changed underneath a full &lt;code&gt;/etc&lt;/code&gt; copy, when you inherit a box with years of &lt;code&gt;override.conf&lt;/code&gt; files, when a config management tool and a human both write drop-ins for the same unit, and any time you add to a list setting without checking what it already holds. Fedora's frequent release cadence makes the first one more likely than on slower distros, because vendor units change often.&lt;/p&gt;

&lt;p&gt;This is the same class of problem as &lt;a href="https://guatulabs.dev/posts/firewall-zone-persistence-why-firewalld-runtime-changes-vanish-and-how-to-pin-them/" rel="noopener noreferrer"&gt;firewalld runtime changes that vanish on reload&lt;/a&gt;. The file you edited and the configuration that's actually running are two different things, and the tooling won't point out the gap.&lt;/p&gt;

&lt;p&gt;What I'd do differently, and what I now do by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never use &lt;code&gt;systemctl edit --full&lt;/code&gt; on a packaged unit. Use a named drop-in.&lt;/li&gt;
&lt;li&gt;Never start a drop-in with an empty assignment unless the same file restates the complete list.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;systemctl show -p &amp;lt;Setting&amp;gt;&lt;/code&gt; after every change to a list setting, not just &lt;code&gt;systemctl status&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;systemd-delta&lt;/code&gt; after every major Fedora upgrade and look at each override.&lt;/li&gt;
&lt;li&gt;Keep one env file per service, and treat any second &lt;code&gt;EnvironmentFile=&lt;/code&gt; as a bug.&lt;/li&gt;
&lt;li&gt;Treat "config fixed" and "service fixed" as two separate checks, and clear learned state when a broken config ran long enough to leave it behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running agent workloads or long-lived services on hosts where this kind of drift has piled up for years, that's work I do through &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs consulting&lt;/a&gt;. Most of the time, though, &lt;code&gt;systemctl cat&lt;/code&gt; and five minutes of reading turn up the problem. The merged config is always available if you ask systemd for it.&lt;/p&gt;

</description>
      <category>systemd</category>
      <category>fedora</category>
      <category>linux</category>
      <category>dropins</category>
    </item>
    <item>
      <title>Firewall Zone Persistence: Why firewalld Runtime Changes Vanish and How to Pin Them</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Wed, 07 Oct 2026 16:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/firewall-zone-persistence-why-firewalld-runtime-changes-vanish-and-how-to-pin-them-4fee</link>
      <guid>https://dev.to/futhgar/firewall-zone-persistence-why-firewalld-runtime-changes-vanish-and-how-to-pin-them-4fee</guid>
      <description>&lt;p&gt;You run &lt;code&gt;firewall-cmd --zone=public --change-interface=wlp2s0&lt;/code&gt;, firewalld answers &lt;code&gt;success&lt;/code&gt;, and &lt;code&gt;--get-active-zones&lt;/code&gt; shows the change. After the next reboot, a &lt;code&gt;firewall-cmd --reload&lt;/code&gt;, or a Wi-Fi reconnect, the interface is back in whatever zone it was in before. firewalld doesn't log anything about it.&lt;/p&gt;

&lt;p&gt;This isn't a bug. firewalld keeps two separate configurations, and on a NetworkManager system a third component also gets a say in which zone an interface belongs to. If you don't know about all three, any change you make by hand will eventually be undone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Expected
&lt;/h2&gt;

&lt;p&gt;My mental model was the iptables one: you run a command, the rule exists, and if you want it to survive a reboot you save it. I knew firewalld had a &lt;code&gt;--permanent&lt;/code&gt; flag, so I assumed the fix was to tack it on and move on.&lt;/p&gt;

&lt;p&gt;There are two problems with that assumption. First, &lt;code&gt;--permanent&lt;/code&gt; on its own doesn't change the running firewall at all. Second, for interfaces managed by NetworkManager, the zone binding isn't really firewalld's to keep. NetworkManager pushes a zone to firewalld every time a connection activates. If you've only told firewalld, your binding gets replaced the next time the connection comes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happens
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Runtime and permanent are two separate configs
&lt;/h3&gt;

&lt;p&gt;firewalld keeps a &lt;strong&gt;runtime&lt;/strong&gt; configuration (what the kernel is enforcing right now) and a &lt;strong&gt;permanent&lt;/strong&gt; configuration (XML files in &lt;code&gt;/etc/firewalld/&lt;/code&gt; that get loaded at startup and on reload). Every &lt;code&gt;firewall-cmd&lt;/code&gt; call writes to exactly one of them:&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;# Runtime only: takes effect now, gone on reload/restart/reboot&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ssh

&lt;span class="c"&gt;# Permanent only: written to disk, NOT active until reload&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ssh

&lt;span class="c"&gt;# Both: the boring, correct pattern&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ssh
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you two ways to fail, and they mirror each other. If you use runtime only, the rule works until the next reload, and a reload is exactly what someone else's Ansible run or package update will trigger. If you use permanent only, the rule doesn't exist yet, so you test it, see it fail, and then "fix" it with a runtime command. Now the two configs disagree and nothing tells you.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--reload&lt;/code&gt; itself is the destructive step that people overlook. A reload throws away the runtime state and rebuilds it from the permanent config. Every runtime-only change you made since the last reload is gone, including the one you made five minutes ago to debug something.&lt;/p&gt;

&lt;h3&gt;
  
  
  NetworkManager owns the zone of the interfaces it manages
&lt;/h3&gt;

&lt;p&gt;This part is less well known. On Fedora, RHEL, and most desktop distros, NetworkManager manages your Ethernet and Wi-Fi interfaces. Each NM connection profile has a &lt;code&gt;connection.zone&lt;/code&gt; property. When the connection activates, NetworkManager calls firewalld over D-Bus and assigns the interface to that zone. If &lt;code&gt;connection.zone&lt;/code&gt; is empty, which is the default for every profile, NM puts the interface in &lt;strong&gt;firewalld's default zone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the sequence goes like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You run &lt;code&gt;firewall-cmd --zone=drop --change-interface=wlp2s0&lt;/code&gt;. firewalld moves the interface. Runtime only.&lt;/li&gt;
&lt;li&gt;Your laptop sleeps, wakes up, and reconnects to Wi-Fi.&lt;/li&gt;
&lt;li&gt;NetworkManager activates the connection and tells firewalld to put &lt;code&gt;wlp2s0&lt;/code&gt; in the zone from the profile (empty, so the default zone).&lt;/li&gt;
&lt;li&gt;Your interface is back in &lt;code&gt;public&lt;/code&gt;. Your &lt;code&gt;drop&lt;/code&gt; binding is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Writing the binding into the permanent zone XML (&lt;code&gt;--permanent --zone=drop --add-interface=wlp2s0&lt;/code&gt;) doesn't reliably fix this, because NM reasserts its own zone on every activation. Newer &lt;code&gt;firewall-cmd&lt;/code&gt; builds detect NM-managed interfaces and print a message about the interface being under NetworkManager's control, and in some cases they forward the change to NM for you. That behavior has changed across versions, so I don't count on it. For an NM-managed interface, I treat NM as the source of truth and set the zone there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zone bindings are per connection, not per interface
&lt;/h3&gt;

&lt;p&gt;There's one more consequence. The zone is attached to the &lt;strong&gt;connection profile&lt;/strong&gt;, not to the device. &lt;code&gt;wlp2s0&lt;/code&gt; on your home network and &lt;code&gt;wlp2s0&lt;/code&gt; at an airport are two separate NM connections with two separate &lt;code&gt;connection.zone&lt;/code&gt; values. Pinning the zone for the home SSID tells NM nothing about the next network you join. A new SSID creates a new profile with an empty zone, so it lands in the default zone.&lt;/p&gt;

&lt;p&gt;That turns out to be the most useful behavior in the whole stack, as long as the default zone is set correctly.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  1. Set zones on the NetworkManager connection, not in firewalld
&lt;/h3&gt;

&lt;p&gt;For any NM-managed interface, put the zone in the 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;# See which connection is active on which device&lt;/span&gt;
nmcli &lt;span class="nt"&gt;-f&lt;/span&gt; NAME,UUID,DEVICE connection show &lt;span class="nt"&gt;--active&lt;/span&gt;

&lt;span class="c"&gt;# Pin the zone on the connection profile (persists in the NM keyfile)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection modify &lt;span class="s2"&gt;"HomeWiFi"&lt;/span&gt; connection.zone home

&lt;span class="c"&gt;# Re-activate so NM pushes the new zone to firewalld now&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli connection up &lt;span class="s2"&gt;"HomeWiFi"&lt;/span&gt;

&lt;span class="c"&gt;# Verify from firewalld's side&lt;/span&gt;
firewall-cmd &lt;span class="nt"&gt;--get-active-zones&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The setting is stored in the connection's keyfile under &lt;code&gt;/etc/NetworkManager/system-connections/&lt;/code&gt;, so it survives reboots and reconnects. It also survives &lt;code&gt;firewall-cmd --reload&lt;/code&gt;, because NM reapplies it the next time the connection activates. Since it's stored per connection, it applies only to that SSID.&lt;/p&gt;

&lt;p&gt;If you'd rather not bounce the connection, &lt;code&gt;sudo nmcli device reapply wlp2s0&lt;/code&gt; usually applies the change without a full disconnect. I still run &lt;code&gt;connection up&lt;/code&gt; when I'm making a change by hand, because the result is unambiguous.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Make the default zone the untrusted one
&lt;/h3&gt;

&lt;p&gt;Every new network falls back to firewalld's default zone. That makes the default zone the most important security decision on a laptop, and most people never change it from what the distro shipped. Fedora Workstation ships &lt;code&gt;FedoraWorkstation&lt;/code&gt; as the default zone, and that zone allows a wide high-port range. That's a reasonable choice for a desktop on a home LAN. It's a bad one for a café.&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;# Check what new networks will land in&lt;/span&gt;
firewall-cmd &lt;span class="nt"&gt;--get-default-zone&lt;/span&gt;

&lt;span class="c"&gt;# Make unknown networks untrusted by default&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--set-default-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--set-default-zone&lt;/code&gt; is one of the few firewalld commands that changes runtime and permanent configuration together. It writes &lt;code&gt;DefaultZone=&lt;/code&gt; to &lt;code&gt;/etc/firewalld/firewalld.conf&lt;/code&gt; and applies the change immediately, so you don't need &lt;code&gt;--permanent&lt;/code&gt; or a reload.&lt;/p&gt;

&lt;p&gt;You end up with an allowlist built on inheritance. Every network is untrusted unless its NM profile explicitly says otherwise. You don't have to remember to lock down a new network. You only have to remember to unlock the networks you trust. If you forget, the failure is that something like a file share doesn't work at home. That's annoying, but it's a much safer way to fail than accidentally running a home-level ruleset on hotel Wi-Fi.&lt;/p&gt;

&lt;p&gt;I build the rest of the mobile hardening on this rule (exit-node activation, DNS lockdown, captive portal handling), and I covered how the dispatcher scripts fit around it in &lt;a href="https://guatulabs.dev/posts/trust-the-tailnet-not-the-network-automating-wi-fi-hardening-with-networkmanager-dispatchers/" rel="noopener noreferrer"&gt;Trust the Tailnet, Not the Network&lt;/a&gt;. The firewalld side is the foundation for all of it: if the zone doesn't persist, nothing built on top of it holds.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bind interfaces NM doesn't manage in firewalld's permanent config
&lt;/h3&gt;

&lt;p&gt;Not every interface goes through NetworkManager. &lt;code&gt;tailscale0&lt;/code&gt; is the one that matters to me. Check how NM sees it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli device status | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'DEVICE|tailscale'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it shows as &lt;code&gt;unmanaged&lt;/code&gt;, NM won't assert a zone for it, and firewalld's permanent config is the right place for the binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;trusted &lt;span class="nt"&gt;--add-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tailscale0
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
firewall-cmd &lt;span class="nt"&gt;--get-zone-of-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tailscale0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bind the &lt;strong&gt;interface&lt;/strong&gt;, not the source range. A common suggestion is &lt;code&gt;--add-source=100.64.0.0/10&lt;/code&gt; on the trusted zone. That trusts any packet carrying a CGNAT-range source address arriving on &lt;strong&gt;any&lt;/strong&gt; interface, including the café Wi-Fi, where someone on the same L2 segment can put whatever source IP they like on a packet. Binding to &lt;code&gt;tailscale0&lt;/code&gt; means the trust applies only to traffic that came out of the WireGuard tunnel and was authenticated by the tailnet. It's the same least-privilege thinking as &lt;a href="https://guatulabs.dev/posts/kubernetes-rbac-building-least-privilege-service-accounts/" rel="noopener noreferrer"&gt;scoping Kubernetes service accounts&lt;/a&gt;, applied to a network interface instead. It's also what lets &lt;a href="https://guatulabs.dev/posts/tailscale-subnet-router-remote-access-without-traditional-vpn/" rel="noopener noreferrer"&gt;subnet routing into a homelab&lt;/a&gt; work without opening the LAN-facing side.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. If you experimented in runtime, promote it on purpose
&lt;/h3&gt;

&lt;p&gt;Sometimes the right workflow really is to try things in runtime and keep what works. firewalld has a command for that:&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;# Snapshot the current runtime state into permanent config&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--runtime-to-permanent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It copies &lt;strong&gt;everything&lt;/strong&gt; in runtime to permanent, including the throwaway rule you added while debugging and meant to remove. Diff the two before you run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$zone&lt;/span&gt; &lt;span class="nt"&gt;--list-all&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$zone&lt;/span&gt; &lt;span class="nt"&gt;--list-all&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output means the runtime and permanent configs agree. Any output shows exactly what a reload would throw away, or what &lt;code&gt;--runtime-to-permanent&lt;/code&gt; would make permanent. I run this diff before every reload on a machine I didn't configure myself.&lt;/p&gt;

&lt;p&gt;Runtime does have one feature I like: &lt;code&gt;--timeout&lt;/code&gt;. When you're testing a rule over SSH and might lock yourself out, a time-limited runtime rule removes itself after the timeout:&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;# Rule expires automatically after 5 minutes&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8443/tcp &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the firewall equivalent of a dead-man's switch. If the rule works, add it with &lt;code&gt;--permanent&lt;/code&gt; and reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shadowing Problem You Create Along the Way
&lt;/h2&gt;

&lt;p&gt;Even when you use &lt;code&gt;--permanent&lt;/code&gt; correctly, it has a side effect people don't notice. Zone definitions ship in &lt;code&gt;/usr/lib/firewalld/zones/&lt;/code&gt;. The first time you permanently modify a zone, firewalld writes a &lt;strong&gt;full copy&lt;/strong&gt; of that zone to &lt;code&gt;/etc/firewalld/zones/&amp;lt;zone&amp;gt;.xml&lt;/code&gt;. From then on, the &lt;code&gt;/etc&lt;/code&gt; copy completely replaces the vendor version. If a later firewalld update changes the shipped definition of &lt;code&gt;public&lt;/code&gt;, you won't get that change, because your local copy wins.&lt;/p&gt;

&lt;p&gt;This is the same trap as editing &lt;code&gt;/etc/systemd/resolved.conf&lt;/code&gt; directly instead of using a drop-in under &lt;code&gt;resolved.conf.d/&lt;/code&gt;: one local edit and you've forked the whole vendor config. firewalld zones don't support drop-ins, so you can't avoid the copy. You can keep an eye on it, though:&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;# Which zones have you forked from vendor defaults?&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/firewalld/zones/

&lt;span class="c"&gt;# Compare your copy against what the package ships&lt;/span&gt;
diff /usr/lib/firewalld/zones/public.xml /etc/firewalld/zones/public.xml

&lt;span class="c"&gt;# Reset a zone back to the vendor definition&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--load-zone-defaults&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep the list of forked zones short and track the &lt;code&gt;/etc/firewalld/zones/&lt;/code&gt; files in configuration management. If a zone XML sits there unmanaged, the next person who looks at the box has no way of knowing whether it was a deliberate choice or someone's debugging session that got saved.&lt;/p&gt;

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

&lt;p&gt;You'll run into this in a few predictable places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Laptops and anything that roams.&lt;/strong&gt; Every reconnect is a NetworkManager activation, and every activation reapplies the zone from the profile. Runtime zone changes on Wi-Fi don't last long. If your security model depends on "I moved the interface to &lt;code&gt;drop&lt;/code&gt; before I connected," it lasts until the first sleep/wake cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Servers managed by config management.&lt;/strong&gt; An Ansible role that runs &lt;code&gt;firewall-cmd --reload&lt;/code&gt; wipes out every runtime-only change since the last reload. Hand-applied emergency rules disappear at the next scheduled run, and the incident comes back. Whatever your config management writes should be the only permanent state on the box, and emergency changes should go into that config management afterward, not just into runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging sessions that turn into production config.&lt;/strong&gt; The opposite mistake is just as common. Someone runs &lt;code&gt;--runtime-to-permanent&lt;/code&gt; to "save the fix" and also saves three experimental port openings. Diff before you promote.&lt;/p&gt;

&lt;p&gt;The habits I'd recommend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pair every &lt;code&gt;--permanent&lt;/code&gt; with a &lt;code&gt;--reload&lt;/code&gt;, or use commands like &lt;code&gt;--set-default-zone&lt;/code&gt; that write both configs.&lt;/li&gt;
&lt;li&gt;On NM-managed interfaces, set zones with &lt;code&gt;nmcli connection modify ... connection.zone&lt;/code&gt;, never with &lt;code&gt;firewall-cmd --change-interface&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Treat the default zone as your policy for unknown networks, and set it to something restrictive.&lt;/li&gt;
&lt;li&gt;Bind trust to interfaces like &lt;code&gt;tailscale0&lt;/code&gt;, not to source ranges that can be spoofed from the local segment.&lt;/li&gt;
&lt;li&gt;Diff runtime against permanent before every reload and every promotion.&lt;/li&gt;
&lt;li&gt;Know which zones you've forked into &lt;code&gt;/etc/firewalld/zones/&lt;/code&gt;, and keep those files under version control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The underlying issue is that firewalld, NetworkManager, and the kernel's nftables ruleset each hold their own piece of state, and &lt;code&gt;firewall-cmd&lt;/code&gt; reports &lt;code&gt;success&lt;/code&gt; while updating only one of them. Once you know which component owns which setting, persistence becomes a matter of writing each setting to its owner. If you're dealing with this across a fleet of edge boxes or mixed Linux hosts and want a second set of eyes on the design, that's the kind of &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;infrastructure work I take on&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>firewalld</category>
      <category>networkmanager</category>
      <category>linux</category>
      <category>security</category>
    </item>
    <item>
      <title>Invisible Text on GNOME: Fixing Qt/KDE Flatpak Theme Conflicts in Sandboxed Environments</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 05 Oct 2026 16:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/invisible-text-on-gnome-fixing-qtkde-flatpak-theme-conflicts-in-sandboxed-environments-31bk</link>
      <guid>https://dev.to/futhgar/invisible-text-on-gnome-fixing-qtkde-flatpak-theme-conflicts-in-sandboxed-environments-31bk</guid>
      <description>&lt;p&gt;The app launched, the tray icon showed up, the window opened, and every label in it was dark gray on a dark gray background. Nothing crashed and nothing printed an error. The text just wasn't readable.&lt;/p&gt;

&lt;p&gt;I ran into this with KTailctl, the Kirigami-based Tailscale GUI, installed as a Flatpak on a Fedora 43 GNOME 50 Wayland session. That combination makes it worse than a normal cosmetic bug. When a VPN client's window comes up blank, your first instinct is that the VPN is broken, not the color palette. I spent the first few minutes checking &lt;code&gt;tailscale status&lt;/code&gt; before I noticed I could select the "invisible" text and it showed up highlighted.&lt;/p&gt;

&lt;p&gt;That's the tell, by the way. If you drag-select across an empty-looking area and words appear, you don't have a rendering bug or a dead backend. You have a theme mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Expected
&lt;/h2&gt;

&lt;p&gt;On a GNOME desktop with dark mode enabled, a Qt app should do one of two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Follow the system preference and render dark, with light text.&lt;/li&gt;
&lt;li&gt;Ignore the preference and render in its default light theme, with dark text.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either is fine. Both give you readable text. What I didn't expect was a third option where the app picks &lt;em&gt;half&lt;/em&gt; of each: the background from one source and the foreground colors from another.&lt;/p&gt;

&lt;p&gt;Flatpak's pitch is that the app brings its own runtime and doesn't care what the host has installed. For libraries, that's great. For theming, it means the app is sitting in a sandbox trying to guess what the desktop outside looks like, and it doesn't always guess consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happened
&lt;/h2&gt;

&lt;p&gt;The short version: a KDE app on GNOME inside a Flatpak has two separate places it can get colors from, and they disagreed.&lt;/p&gt;

&lt;p&gt;Here's the longer version. A Kirigami/QML app like KTailctl doesn't render with a single palette. Roughly, it has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Qt palette&lt;/strong&gt; (&lt;code&gt;QPalette&lt;/code&gt;), which comes from the Qt &lt;em&gt;platform theme&lt;/em&gt; plugin. On GNOME, with &lt;code&gt;QT_QPA_PLATFORMTHEME&lt;/code&gt; unset, Qt sees &lt;code&gt;XDG_CURRENT_DESKTOP=GNOME&lt;/code&gt; and loads its built-in GNOME theme. Recent Qt 6 releases read the dark/light preference through the XDG desktop portal (&lt;code&gt;org.freedesktop.appearance&lt;/code&gt; → &lt;code&gt;color-scheme&lt;/code&gt;), so this side says "dark."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The KDE color scheme&lt;/strong&gt; (&lt;code&gt;KColorScheme&lt;/code&gt;), which Kirigami's &lt;code&gt;org.kde.desktop&lt;/code&gt; style uses for a lot of its text and control colors. That comes from &lt;code&gt;kdeglobals&lt;/code&gt;. Inside the sandbox, the app's config lives under &lt;code&gt;~/.var/app/&amp;lt;app-id&amp;gt;/config/&lt;/code&gt;, and on a GNOME machine there's usually no &lt;code&gt;kdeglobals&lt;/code&gt; there at all. No file means KDE's defaults, and the defaults are Breeze light, which means dark text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put those together and you get a dark window background from the Qt palette with dark text from the KDE color scheme. Invisible.&lt;/p&gt;

&lt;p&gt;On a Plasma desktop this never happens. Plasma ships the &lt;code&gt;kde&lt;/code&gt; platform theme plugin and a real &lt;code&gt;kdeglobals&lt;/code&gt;, so both sources agree because they're the same source. On GNOME outside a sandbox, distro packages often patch around it or pull in integration plugins. Inside a Flatpak you get neither. The host's GTK settings don't apply, the host's &lt;code&gt;~/.config/kdeglobals&lt;/code&gt; (if you even have one) isn't visible, and the only bridge to the host preference is the portal, which only one of the two color sources reads.&lt;/p&gt;

&lt;p&gt;Which side "wins" depends on the exact runtime version. I saw it on &lt;code&gt;org.kde.Platform&lt;/code&gt; 6.10. It isn't a new class of bug. Qt 5-era Flatpaks had the same problem, and the old fix was the &lt;code&gt;QGnomePlatform&lt;/code&gt; theme extension, which has since been archived upstream. Qt 6 is supposed to handle GNOME natively, and for plain Qt Widgets apps it mostly does. Kirigami apps that also read KDE color schemes are where it falls apart.&lt;/p&gt;

&lt;p&gt;Libadwaita doesn't help. GNOME's move to Libadwaita means there's less of a GTK theme for anything to imitate, and the &lt;code&gt;color-scheme&lt;/code&gt; portal key is now the only reliable signal of what the user wants. If an app's theming stack doesn't fully consume that one key, it ends up with a split palette.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing It Before Changing Anything
&lt;/h2&gt;

&lt;p&gt;Before you start throwing environment variables at it, confirm what you're dealing with. First, which runtime is the app on:&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;# Which runtime and version the app is built against&lt;/span&gt;
flatpak info &lt;span class="nt"&gt;--show-runtime&lt;/span&gt; org.fkoehler.KTailctl
&lt;span class="c"&gt;# org.kde.Platform/x86_64/6.10&lt;/span&gt;

&lt;span class="c"&gt;# Any overrides already applied (Flatseal writes here too)&lt;/span&gt;
flatpak override &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--show&lt;/span&gt; org.fkoehler.KTailctl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, check what the host is actually advertising through the portal. This is what the sandboxed app sees, not what GNOME Settings shows you:&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;# 0 = no preference, 1 = prefer dark, 2 = prefer light&lt;/span&gt;
gdbus call &lt;span class="nt"&gt;--session&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dest&lt;/span&gt; org.freedesktop.portal.Desktop &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--object-path&lt;/span&gt; /org/freedesktop/portal/desktop &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--method&lt;/span&gt; org.freedesktop.portal.Settings.ReadOne &lt;span class="se"&gt;\&lt;/span&gt;
  org.freedesktop.appearance color-scheme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns &lt;code&gt;&amp;lt;uint32 1&amp;gt;&lt;/code&gt;, the app is being told to go dark. Now look at which platform theme plugins the runtime actually ships, because you can only select ones that exist inside the sandbox:&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;# List platform theme plugins visible inside the sandbox&lt;/span&gt;
flatpak run &lt;span class="nt"&gt;--command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sh org.fkoehler.KTailctl &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'find / -path "*platformthemes*" -name "*.so" 2&amp;gt;/dev/null'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check whether the app has a KDE config of its own:&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;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.var/app/org.fkoehler.KTailctl/config/
&lt;span class="c"&gt;# no kdeglobals here = KColorScheme falls back to Breeze light&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, you can make Qt tell you which platform theme it actually loaded. It's noisy, but grep makes it usable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak run &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;QT_DEBUG_PLUGINS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 org.fkoehler.KTailctl 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; platformtheme
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dark mode from the portal, no &lt;code&gt;kdeglobals&lt;/code&gt;, and the GNOME platform theme loaded: that's the split-palette setup described above.&lt;/p&gt;

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

&lt;p&gt;Whatever you do, the goal is to give the app &lt;strong&gt;one source of truth&lt;/strong&gt; for colors. You can make both sides agree on dark, or make both sides agree on light. What you can't do is leave them reading from different places.&lt;/p&gt;

&lt;p&gt;Test every option with a one-off &lt;code&gt;flatpak run --env=...&lt;/code&gt; first. It doesn't persist anything, so a bad guess costs you nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: Use the KDE platform theme and give it a color scheme
&lt;/h3&gt;

&lt;p&gt;This is the fix I prefer for Kirigami apps. It makes the Qt palette and &lt;code&gt;KColorScheme&lt;/code&gt; both come from &lt;code&gt;kdeglobals&lt;/code&gt;, so they can't disagree.&lt;/p&gt;

&lt;p&gt;First, test it without persisting anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak run &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;QT_QPA_PLATFORMTHEME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;kde org.fkoehler.KTailctl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the runtime includes the &lt;code&gt;kde&lt;/code&gt; platform theme plugin (the &lt;code&gt;find&lt;/code&gt; above will tell you), the app should now render consistently. On GNOME that will probably be consistently &lt;em&gt;light&lt;/em&gt;, since there's still no &lt;code&gt;kdeglobals&lt;/code&gt;. Light and readable is already a win. If you want dark, give the app a dark scheme. The KDE runtime usually ships the Breeze color scheme files, and their format is compatible with &lt;code&gt;kdeglobals&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;APP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;org.fkoehler.KTailctl
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.var/app/&lt;span class="nv"&gt;$APP&lt;/span&gt;/config

&lt;span class="c"&gt;# Copy Breeze Dark from inside the runtime into the app's private kdeglobals&lt;/span&gt;
flatpak run &lt;span class="nt"&gt;--command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt; /usr/share/color-schemes/BreezeDark.colors &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.var/app/&lt;span class="nv"&gt;$APP&lt;/span&gt;/config/kdeglobals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that path doesn't exist in your runtime version, run &lt;code&gt;ls /usr/share/color-schemes/&lt;/code&gt; inside the sandbox to see what's there. If you have Plasma color schemes installed on the host, you can also copy one over from there. Once it renders the way you want, persist the environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak override &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;QT_QPA_PLATFORMTHEME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;kde org.fkoehler.KTailctl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tradeoff is that the app stops following GNOME's dark/light toggle. It uses whatever scheme you put in its &lt;code&gt;kdeglobals&lt;/code&gt;. For a tray utility I open a few times a day, I'm fine with that. If you switch modes on a schedule, keep reading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: Force a single Qt Quick style
&lt;/h3&gt;

&lt;p&gt;If the app is Kirigami/QML, the split usually comes from the &lt;code&gt;org.kde.desktop&lt;/code&gt; style pulling colors from &lt;code&gt;KColorScheme&lt;/code&gt;. Forcing a style that only reads the Qt palette removes the second source:&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;# Test first&lt;/span&gt;
flatpak run &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;QT_QUICK_CONTROLS_STYLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Fusion org.fkoehler.KTailctl

&lt;span class="c"&gt;# Persist if it looks right&lt;/span&gt;
flatpak override &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;QT_QUICK_CONTROLS_STYLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Fusion org.fkoehler.KTailctl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent Qt 6 releases make Fusion generate a dark palette when the platform reports a dark color scheme, so this keeps the portal-driven dark/light behavior. The downside is cosmetic: it won't look like Breeze. Some Kirigami components also look a bit off outside their native style. Readable and slightly plain beats native-looking and blank.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: Force the GTK platform theme
&lt;/h3&gt;

&lt;p&gt;The research floating around for this issue often suggests &lt;code&gt;QT_QPA_PLATFORMTHEME=gtk&lt;/code&gt;. For Qt 6 the plugin is named &lt;code&gt;gtk3&lt;/code&gt;, not &lt;code&gt;gtk&lt;/code&gt;, and it only works if the runtime actually ships it. Most of the time the &lt;code&gt;find&lt;/code&gt; command above won't list it in the KDE runtime, and Qt quietly falls back to the default when you name a plugin that doesn't exist. That's one more silent failure on top of the first one. If your runtime does include it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak run &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;QT_QPA_PLATFORMTHEME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gtk3 org.fkoehler.KTailctl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check with &lt;code&gt;QT_DEBUG_PLUGINS=1&lt;/code&gt; that it actually loaded before you trust it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick fix vs. permanent fix
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Persists&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Undo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flatpak run --env=VAR=value &amp;lt;app&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;That one launch&lt;/td&gt;
&lt;td&gt;Close the app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flatpak override --user --env=...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Your user, that app&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flatpak override --user --reset &amp;lt;app&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flatseal "Environment" section&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Same as above (writes the same override file)&lt;/td&gt;
&lt;td&gt;Delete the entry in Flatseal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;flatpak override --user --env=...&lt;/code&gt; with no app ID&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Every&lt;/strong&gt; Flatpak for your user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;flatpak override --user --reset&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Don't use the last row casually. Setting &lt;code&gt;QT_QPA_PLATFORMTHEME=kde&lt;/code&gt; globally will also apply to Qt apps on runtimes that don't ship that plugin, and you'll be debugging a different app next week. Scope overrides to the app ID.&lt;/p&gt;

&lt;p&gt;To undo everything and get back to a clean state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak override &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--reset&lt;/span&gt; org.fkoehler.KTailctl
&lt;span class="nb"&gt;rm&lt;/span&gt; ~/.var/app/org.fkoehler.KTailctl/config/kdeglobals
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;When you'll hit this, specifically: a Qt or KDE app (especially a Kirigami/QML one) installed from Flathub, running on GNOME or another non-Plasma desktop, with dark mode on. Wayland makes it more likely, because the old X11-era hacks of reading X resources or the host's GTK settings files are even further out of reach. Every runtime bump is a chance for the behavior to change, so an app that rendered fine last month can turn invisible after a &lt;code&gt;flatpak update&lt;/code&gt; with no change on your side.&lt;/p&gt;

&lt;p&gt;The part that stays with me isn't the fix. It's how much time a failure like this takes when nothing tells you it's a failure. A crash gives you a stack trace. A blank window that responds to clicks gives you nothing, and it pushes you toward the wrong subsystem. With a VPN client, "the UI shows nothing" sounds a lot like "it isn't connected," so you end up debugging networking that works fine. I wrote about the same pattern in a very different context in &lt;a href="https://guatulabs.dev/posts/blog-pipeline-fence-corruption-how-a-naive-sed-rule-swallowed-markdown-code-blocks/" rel="noopener noreferrer"&gt;the blog pipeline fence corruption post&lt;/a&gt;: output that looks plausible and is quietly wrong is worse than output that fails loudly, because none of your error detection fires.&lt;/p&gt;

&lt;p&gt;The sandbox angle is the other lesson. Flatpak isolates the app from host libraries, which is the point. It also isolates the app from host &lt;em&gt;configuration&lt;/em&gt;, which people forget. A theme file, a &lt;code&gt;/tmp&lt;/code&gt; path, a GPU driver: each one is something the app assumes about its environment, and the sandbox can quietly invalidate that assumption. I hit the &lt;code&gt;/tmp&lt;/code&gt; version of this with &lt;a href="https://guatulabs.dev/posts/claude-headless-sandboxing-why-tmp-writes-fail-in-non-interactive-mode/" rel="noopener noreferrer"&gt;Claude running headless in a sandbox&lt;/a&gt;, and the driver version shows up with &lt;a href="https://guatulabs.dev/posts/nvidia-container-toolkit-why-the-default-runtime-matters/" rel="noopener noreferrer"&gt;the NVIDIA container toolkit&lt;/a&gt;. Different layers, same shape: the host and the container each have a version of the truth, and the bug lives in the gap between them.&lt;/p&gt;

&lt;p&gt;What I'd do differently, and what I'd tell anyone running Qt Flatpaks on GNOME:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Select-all first.&lt;/strong&gt; &lt;code&gt;Ctrl+A&lt;/code&gt; or a drag-select on a blank-looking window tells you in two seconds whether the content is there. Do it before you touch the network stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the portal, not the settings panel.&lt;/strong&gt; The &lt;code&gt;gdbus&lt;/code&gt; &lt;code&gt;ReadOne&lt;/code&gt; call shows you what the sandbox actually receives. GNOME Settings shows you what GNOME thinks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick one color source on purpose.&lt;/strong&gt; Either the KDE platform theme plus an app-local &lt;code&gt;kdeglobals&lt;/code&gt;, or a Qt Quick style that only reads the Qt palette. Mixing them is the bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the plugin exists before you name it.&lt;/strong&gt; &lt;code&gt;QT_QPA_PLATFORMTHEME&lt;/code&gt; pointing at a missing plugin fails silently. &lt;code&gt;find&lt;/code&gt; inside the sandbox plus &lt;code&gt;QT_DEBUG_PLUGINS=1&lt;/code&gt; takes thirty seconds and saves you from stacking a second silent failure on the first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope overrides per app.&lt;/strong&gt; A global override works until it hits a runtime that doesn't match, and then you're back to staring at a blank window wondering what changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is in the Flatpak docs or the app's README, because technically nothing is broken. Each component is doing what it was designed to do. The bug only shows up when you put them together.&lt;/p&gt;

</description>
      <category>flatpak</category>
      <category>gnome</category>
      <category>qt</category>
      <category>kde</category>
    </item>
    <item>
      <title>Trust the Tailnet, Not the Network: Automating Wi-Fi Hardening with NetworkManager Dispatchers</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 02 Oct 2026 16:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/trust-the-tailnet-not-the-network-automating-wi-fi-hardening-with-networkmanager-dispatchers-1bjm</link>
      <guid>https://dev.to/futhgar/trust-the-tailnet-not-the-network-automating-wi-fi-hardening-with-networkmanager-dispatchers-1bjm</guid>
      <description>&lt;p&gt;A stock Fedora Workstation laptop on coffee shop Wi-Fi accepts inbound TCP and UDP on every port from 1025 to 65535, plus SSH. That's the default &lt;code&gt;FedoraWorkstation&lt;/code&gt; zone, and every new Wi-Fi profile lands in it unless you tell NetworkManager otherwise. Tailscale shows "Connected" in the tray the whole time, which makes it easy to assume you're covered. Unless you've set an exit node, though, your DNS queries and web traffic go straight out the local access point like everyone else's.&lt;/p&gt;

&lt;p&gt;This post is for anyone who runs Tailscale on a Linux laptop and carries it onto networks they don't control: hotels, airports, conference Wi-Fi. The idea is simple. &lt;strong&gt;No Wi-Fi network is trusted.&lt;/strong&gt; The Wi-Fi is a dumb pipe, the tailnet is the security boundary, and a NetworkManager dispatcher script enforces that on every connect so you don't have to remember to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default state is worse than you think
&lt;/h2&gt;

&lt;p&gt;Here's what an untouched Fedora Workstation looks like after joining a new network. The interface name and SSID are examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; connection.zone connection show &lt;span class="s2"&gt;"CoffeeShop"&lt;/span&gt;
                                  &lt;span class="c"&gt;# empty: falls back to the default zone&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--get-zone-of-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wlp0s20f3
FedoraWorkstation
&lt;span class="nv"&gt;$ &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;FedoraWorkstation &lt;span class="nt"&gt;--list-all&lt;/span&gt;
FedoraWorkstation &lt;span class="o"&gt;(&lt;/span&gt;active&lt;span class="o"&gt;)&lt;/span&gt;
  target: default
  interfaces: wlp0s20f3
  services: dhcpv6-client samba-client ssh
  ports: 1025-65535/udp 1025-65535/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty &lt;code&gt;connection.zone&lt;/code&gt; means "use whatever the default zone is." On Workstation, the default zone was designed so local dev servers and casual LAN sharing just work. That's a reasonable desktop default at home. On a network shared with 200 strangers it means any dev server you left running on &lt;code&gt;0.0.0.0:8000&lt;/code&gt;, any Jupyter kernel and any debug port is reachable by everyone on the same access point.&lt;/p&gt;

&lt;p&gt;Tailscale doesn't change any of this. It adds a &lt;code&gt;tailscale0&lt;/code&gt; interface and routes &lt;code&gt;100.64.0.0/10&lt;/code&gt; (plus whatever subnet routes you accept) through it. Everything else, including your DNS lookups unless MagicDNS is answering them, still exits via &lt;code&gt;wlp0s20f3&lt;/code&gt;. If you've read my post on &lt;a href="https://guatulabs.dev/posts/tailscale-subnet-router-remote-access-without-traditional-vpn/" rel="noopener noreferrer"&gt;Tailscale subnet routers&lt;/a&gt;, this is the other half of that story: the subnet router gets you into your LAN, but it does nothing for traffic that isn't headed there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Per-SSID allowlists
&lt;/h3&gt;

&lt;p&gt;The first instinct is to classify networks: home is trusted, everything else gets the strict zone. You set &lt;code&gt;connection.zone&lt;/code&gt; on each profile by hand.&lt;/p&gt;

&lt;p&gt;This falls apart for two reasons. The first is that the set of "everything else" is unbounded. Every new hotel, every airport lounge, every "Guest-5G" creates a new profile, and each new profile inherits the permissive default zone. One forgotten profile and you're wide open. That's configuration drift with a nasty failure mode, because nothing tells you it happened.&lt;/p&gt;

&lt;p&gt;The second is that it's backwards. An allowlist of &lt;em&gt;safe&lt;/em&gt; networks means the default for unknown networks is &lt;em&gt;unsafe&lt;/em&gt;. You want the inverse: everything is hostile, and the exceptions are explicit and small.&lt;/p&gt;

&lt;h3&gt;
  
  
  Runtime-only fixes
&lt;/h3&gt;

&lt;p&gt;The other trap is fixing things live and calling it done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;public &lt;span class="nt"&gt;--change-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wlp0s20f3
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--exit-node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;exit-a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of these work immediately. The firewall change is runtime-only, so it evaporates on the next reconnect when NetworkManager reapplies the profile's zone (empty, so default). The exit node setting does persist in Tailscale's prefs, which sounds good until you get home and wonder why your LAN printer vanished and every request is hairpinning through a remote node. You end up toggling it by hand, and then forgetting to toggle it back on the next trip.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tailscale's built-in auto exit node
&lt;/h3&gt;

&lt;p&gt;Recent Tailscale releases have &lt;code&gt;tailscale exit-node suggest&lt;/code&gt; and &lt;code&gt;--exit-node=auto:any&lt;/code&gt;. These pick an exit node for you, optimized for latency. What they don't do is know anything about the network you're on. There's no "use an exit node only on untrusted Wi-Fi" setting, and as far as I can tell SSID-aware routing still lives in long-running feature requests on GitHub rather than in the client. So the policy decision has to happen locally, and NetworkManager is the thing that knows when you joined a network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trusting the CGNAT range
&lt;/h3&gt;

&lt;p&gt;For the "allow SSH from my tailnet" half, a common suggestion is:&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;# Don't do this&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;trusted &lt;span class="nt"&gt;--add-source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100.64.0.0/10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firewalld source bindings match on source address regardless of which interface the packet arrived on, and source zones take precedence over interface zones. A host on the same Wi-Fi can put &lt;code&gt;100.64.1.1&lt;/code&gt; in the source field of a packet and send it to your &lt;code&gt;wlp0s20f3&lt;/code&gt; address. The return path for a TCP handshake would go out &lt;code&gt;tailscale0&lt;/code&gt;, which limits what an attacker gets, but UDP services and anything stateless are fair game, and "the routing table will probably save me" isn't a security model.&lt;/p&gt;

&lt;p&gt;Tailscale's default netfilter mode on Linux does install a rule dropping &lt;code&gt;100.64.0.0/10&lt;/code&gt; sources that don't arrive on &lt;code&gt;tailscale0&lt;/code&gt;. That's good defense in depth. I still don't want my firewall policy to be correct only because another daemon happened to insert a rule I didn't write. Bind to the interface instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual solution
&lt;/h2&gt;

&lt;p&gt;There are three pieces: two firewalld zones, a dispatcher script, and a tiny opt-out list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: A zone for hostile Wi-Fi
&lt;/h3&gt;

&lt;p&gt;Fedora's built-in &lt;code&gt;public&lt;/code&gt; zone is the usual recommendation, but on Fedora it still allows &lt;code&gt;ssh&lt;/code&gt; and &lt;code&gt;mdns&lt;/code&gt;. I'd rather not expose sshd on an airport network, so I build a dedicated zone with almost nothing in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--new-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hostile-wifi
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hostile-wifi &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dhcpv6-client
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hostile-wifi &lt;span class="nt"&gt;--list-all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zone target stays at &lt;code&gt;default&lt;/code&gt;, which rejects unsolicited inbound traffic. Established and related return traffic is accepted by firewalld's base rules, so outbound browsing works normally. DHCPv4 doesn't need a service entry because NetworkManager's DHCP client uses a raw socket. If you'd rather drop silently than reject, &lt;code&gt;--set-target=DROP&lt;/code&gt; works, though I'd test IPv6 on a few networks before committing to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: A zone for the tailnet, bound to the interface
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--new-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tailnet
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tailnet &lt;span class="nt"&gt;--add-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tailscale0
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--permanent&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tailnet &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ssh
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NetworkManager doesn't manage &lt;code&gt;tailscale0&lt;/code&gt;, so a permanent firewalld interface binding sticks here (unlike Wi-Fi interfaces, where NM owns the zone assignment). Now SSH is reachable only on packets that actually arrived through the WireGuard tunnel, which means they were authenticated by a tailnet peer key. That's the whole point: the trust decision is cryptographic, not based on an address someone can type into a packet. Pair it with Tailscale ACLs to decide &lt;em&gt;which&lt;/em&gt; tailnet peers can reach port 22, the same least-privilege thinking I use for &lt;a href="https://guatulabs.dev/posts/kubernetes-rbac-building-least-privilege-service-accounts/" rel="noopener noreferrer"&gt;Kubernetes service accounts&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The dispatcher script
&lt;/h3&gt;

&lt;p&gt;NetworkManager runs every executable in &lt;code&gt;/etc/NetworkManager/dispatcher.d/&lt;/code&gt; on network events, in alphabetical order, passing the interface as &lt;code&gt;$1&lt;/code&gt; and the action as &lt;code&gt;$2&lt;/code&gt;. I care about two actions: &lt;code&gt;up&lt;/code&gt; (a connection activated) and &lt;code&gt;connectivity-change&lt;/code&gt; (NM's connectivity check moved between states like &lt;code&gt;portal&lt;/code&gt; and &lt;code&gt;full&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The script is split into three parts here so each stays readable. First, the setup and filtering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# /etc/NetworkManager/dispatcher.d/50-wifi-safe&lt;/span&gt;
&lt;span class="c"&gt;# Every Wi-Fi profile is hostile unless its UUID is explicitly listed.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;

&lt;span class="nv"&gt;ACTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;EXIT_NODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"exit-a"&lt;/span&gt;                            &lt;span class="c"&gt;# MagicDNS name or 100.x IP of your exit node&lt;/span&gt;
&lt;span class="nv"&gt;HOSTILE_ZONE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"hostile-wifi"&lt;/span&gt;
&lt;span class="nv"&gt;TRUSTED_LIST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/etc/wifi-safe/trusted-uuids"&lt;/span&gt;   &lt;span class="c"&gt;# one connection UUID per line&lt;/span&gt;

log&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; logger &lt;span class="nt"&gt;-t&lt;/span&gt; wifi-safe &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ACTION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;up|connectivity-change&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;

&lt;span class="c"&gt;# connectivity-change isn't tied to an interface, so find the active Wi-Fi profile ourselves&lt;/span&gt;
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; UUID DEV &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;nmcli &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; TYPE,UUID,DEVICE connection show &lt;span class="nt"&gt;--active&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;: &lt;span class="s1"&gt;'$1=="802-11-wireless" {print $2, $3; exit}'&lt;/span&gt;&lt;span class="o"&gt;)&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="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UUID&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I deliberately don't use &lt;code&gt;set -e&lt;/code&gt;. A dispatcher script that dies halfway through on a non-zero &lt;code&gt;grep&lt;/code&gt; leaves the machine in a half-hardened state with no log line explaining why. I'd rather check each step and log it.&lt;/p&gt;

&lt;p&gt;Second, the trust check and zone pinning:&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="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRUSTED_LIST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qxF&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TRUSTED_LIST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"trusted profile &lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt; on &lt;/span&gt;&lt;span class="nv"&gt;$DEV&lt;/span&gt;&lt;span class="s2"&gt;, clearing exit node"&lt;/span&gt;
  tailscale &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--exit-node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"failed to clear exit node"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Persist the zone on the profile so reconnects and reboots keep it...&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="si"&gt;$(&lt;/span&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; connection.zone connection show &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOSTILE_ZONE&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;nmcli connection modify &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; connection.zone &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOSTILE_ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; log &lt;span class="s2"&gt;"pinned &lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt; to zone &lt;/span&gt;&lt;span class="nv"&gt;$HOSTILE_ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# ...and apply it now, because 'modify' doesn't touch the live device&lt;/span&gt;
firewall-cmd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOSTILE_ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--change-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEV&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="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"firewall-cmd failed for &lt;/span&gt;&lt;span class="nv"&gt;$DEV&lt;/span&gt;&lt;span class="s2"&gt;, interface may be in the default zone"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two-step zone handling is the runtime-vs-permanent fix from earlier. &lt;code&gt;nmcli connection modify&lt;/code&gt; writes the zone into the profile, so the next activation of that network comes up hostile before the dispatcher even runs. &lt;code&gt;firewall-cmd --change-interface&lt;/code&gt; covers the current session. On the first connection to a brand-new network, there's a short window where the interface sits in the default zone before this runs. More on that in the lessons section.&lt;/p&gt;

&lt;p&gt;Third, the captive portal check and the exit node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;nmcli &lt;span class="nt"&gt;-t&lt;/span&gt; networking connectivity&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"full"&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;log &lt;span class="s2"&gt;"connectivity=&lt;/span&gt;&lt;span class="nv"&gt;$state&lt;/span&gt;&lt;span class="s2"&gt; on &lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt;, deferring exit node"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0      &lt;span class="c"&gt;# a later connectivity-change event will re-run us&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;tailscale status &lt;span class="nt"&gt;--json&lt;/span&gt; 2&amp;gt;/dev/null | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.BackendState'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$backend&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"Running"&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;log &lt;span class="s2"&gt;"tailscaled state=&lt;/span&gt;&lt;span class="nv"&gt;$backend&lt;/span&gt;&lt;span class="s2"&gt; on &lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt;: NOT PROTECTED"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

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="si"&gt;$(&lt;/span&gt;tailscale status &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.ExitNodeStatus.ID // empty'&lt;/span&gt;&lt;span class="si"&gt;)&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;tailscale &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--exit-node&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXIT_NODE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--exit-node-allow-lan-access&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; log &lt;span class="s2"&gt;"exit node &lt;/span&gt;&lt;span class="nv"&gt;$EXIT_NODE&lt;/span&gt;&lt;span class="s2"&gt; enabled for &lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"FAILED to set exit node for &lt;/span&gt;&lt;span class="nv"&gt;$UUID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The captive portal check matters more than it looks. If you force all traffic through an exit node while the hotel network is still intercepting HTTP for its login page, the portal never loads. Your browser's request goes through the tunnel, reaches the real internet, and you sit there with "Connected, no internet" forever. NetworkManager's connectivity check reports &lt;code&gt;portal&lt;/code&gt; in that state. The script backs off, you log in, NM's next check flips to &lt;code&gt;full&lt;/code&gt;, a &lt;code&gt;connectivity-change&lt;/code&gt; event fires, and the script runs again and sets the exit node.&lt;/p&gt;

&lt;p&gt;The script needs &lt;code&gt;jq&lt;/code&gt;. You could parse &lt;code&gt;tailscale status&lt;/code&gt; text output instead, but the text format isn't a stable interface and the JSON one is close enough to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Install it with the right ownership
&lt;/h3&gt;

&lt;p&gt;NetworkManager is picky about dispatcher scripts, and it's quiet about it. The script must be owned by root, executable, and not writable by group or others. If any of that is wrong, the dispatcher skips it and the only trace is at debug log level.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; root &lt;span class="nt"&gt;-g&lt;/span&gt; root &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 50-wifi-safe /etc/NetworkManager/dispatcher.d/
&lt;span class="nb"&gt;sudo &lt;/span&gt;restorecon &lt;span class="nt"&gt;-v&lt;/span&gt; /etc/NetworkManager/dispatcher.d/50-wifi-safe   &lt;span class="c"&gt;# fix SELinux label on Fedora&lt;/span&gt;
systemctl is-enabled NetworkManager-dispatcher.service

&lt;span class="c"&gt;# Opt out your home network by UUID, not by SSID&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/wifi-safe
nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; connection.uuid connection show &lt;span class="s2"&gt;"HomeWifi"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/wifi-safe/trusted-uuids
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;restorecon&lt;/code&gt; line is the one people skip. If you write the script in your home directory and &lt;code&gt;mv&lt;/code&gt; it into place, it keeps the &lt;code&gt;user_home_t&lt;/code&gt; label, and SELinux will stop the dispatcher from executing it. &lt;code&gt;install&lt;/code&gt; creates a new file, which usually gets the right label from the directory, but running &lt;code&gt;restorecon&lt;/code&gt; costs nothing. If the script runs but &lt;code&gt;tailscale&lt;/code&gt; or &lt;code&gt;firewall-cmd&lt;/code&gt; calls fail inside it, check &lt;code&gt;sudo ausearch -m avc -ts recent&lt;/code&gt; for denials before you start rewriting logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Verify, and know where to look
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli connection down &lt;span class="s2"&gt;"CoffeeShop"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; nmcli connection up &lt;span class="s2"&gt;"CoffeeShop"&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-t&lt;/span&gt; wifi-safe &lt;span class="nt"&gt;-f&lt;/span&gt;                      &lt;span class="c"&gt;# our own log lines&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; NetworkManager-dispatcher &lt;span class="nt"&gt;-f&lt;/span&gt;      &lt;span class="c"&gt;# whether NM ran the script at all&lt;/span&gt;
firewall-cmd &lt;span class="nt"&gt;--get-zone-of-interface&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;wlp0s20f3  &lt;span class="c"&gt;# expect: hostile-wifi&lt;/span&gt;
tailscale status &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="s1"&gt;'.ExitNodeStatus'&lt;/span&gt;  &lt;span class="c"&gt;# expect: non-null, Online: true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;journalctl -t wifi-safe&lt;/code&gt; shows nothing at all, the script didn't run, and the problem is ownership, permissions, SELinux, or the dispatcher service. If it shows lines but the state is wrong, the problem is inside the script. Splitting the logs this way cuts the debugging space in half on the first look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;p&gt;The design rests on moving trust from the network to the tunnel. Three properties make that hold up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deny by default, opt out by exception.&lt;/strong&gt; Every Wi-Fi profile that isn't on a short, explicit list gets the hostile zone and the exit node. A new network can't slip through because you forgot to configure it. Forgetting is the safe outcome. The worst case of a missing trusted entry is that your home traffic takes a detour through the exit node, which is annoying and harmless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interface-bound policy instead of address-bound policy.&lt;/strong&gt; Packets on &lt;code&gt;tailscale0&lt;/code&gt; have already been decrypted by WireGuard using a peer's key, so the interface itself carries an authentication guarantee that a source address doesn't. A &lt;code&gt;--add-source=100.64.0.0/10&lt;/code&gt; rule asks "does this packet claim to be from the tailnet?" An &lt;code&gt;--add-interface=tailscale0&lt;/code&gt; rule asks "did this packet come out of the tunnel?" Only the second question has an answer an attacker on the same Wi-Fi can't forge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event-driven reconciliation, idempotently.&lt;/strong&gt; The script doesn't assume anything about previous state. It checks the profile's zone and the exit node status each time and only changes what's wrong. That's the same shape as a Kubernetes controller: observe, compare, act. It's why I'm comfortable having it fire on every &lt;code&gt;up&lt;/code&gt; and every &lt;code&gt;connectivity-change&lt;/code&gt; without worrying about it stacking up side effects.&lt;/p&gt;

&lt;p&gt;The exit node closes the egress half. With &lt;code&gt;--exit-node&lt;/code&gt; set, Tailscale installs a default route through &lt;code&gt;tailscale0&lt;/code&gt; and the local network only sees encrypted WireGuard UDP to the exit node (or a DERP relay). DNS goes along for the ride: with &lt;code&gt;--accept-dns&lt;/code&gt; on, the system resolver points at Tailscale's &lt;code&gt;100.100.100.100&lt;/code&gt;, and non-tailnet lookups get forwarded via the exit node rather than handed to whatever resolver the coffee shop's DHCP offered. Setting &lt;code&gt;--exit-node-allow-lan-access=false&lt;/code&gt; matters on hostile networks, because the alternative carves the local subnet back out of the tunnel and lets you talk to (and be talked to by) your neighbors.&lt;/p&gt;

&lt;p&gt;When the exit node itself goes offline, my understanding is that the Tailscale client keeps the route pinned and traffic stops instead of silently falling back to the local network. That's fail-closed behavior, and it's the right call for this setup, but it means your exit node is now a dependency for internet access whenever you travel. I run mine on always-on hardware at home and I'd suggest the same. A second exit node in a different location is cheap insurance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;There's a leak window, and you should know its size.&lt;/strong&gt; Between Wi-Fi association and the dispatcher completing, the machine is on the network with whatever zone the profile had, and egress isn't tunneled yet. For a known profile, the zone is already pinned from last time, so it's only the egress window. For a brand-new network, the first few seconds are in the default zone. If that matters for your threat model, change the default zone itself (&lt;code&gt;firewall-cmd --set-default-zone=hostile-wifi&lt;/code&gt;) and put your home profile in a more permissive zone explicitly. That flips the defaults at the firewall layer too, and it's what I'd do on a laptop that travels a lot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Captive portals expire mid-session.&lt;/strong&gt; Hotel networks commonly re-prompt after 24 hours. With the exit node already active, the portal can't intercept you, so you'll see a dead connection instead of a login page. The fix is manual: &lt;code&gt;tailscale set --exit-node=&lt;/code&gt;, log in, and let the next &lt;code&gt;connectivity-change&lt;/code&gt; re-enable it. I haven't found a clean way to automate the reverse transition without the script turning the exit node off whenever connectivity flickers, which would defeat the purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UUIDs are better than SSIDs, but they aren't magic.&lt;/strong&gt; I key the trusted list on the NM connection UUID so that a network called "HomeWifi" in a hotel lobby is a new profile, not a trusted one. That stops casual collisions. It doesn't stop an evil twin that clones your home SSID &lt;em&gt;and&lt;/em&gt; knows your WPA2-PSK password, because NM will happily auto-connect that to your existing profile. If your home network uses WPA3-SAE or 802.1X, that's a much harder attack. If it's a PSK you've handed to every guest for five years, treat the trusted list as a convenience feature, not a security control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep dispatcher scripts fast.&lt;/strong&gt; The dispatcher runs scripts sequentially, so a script blocked on a hung &lt;code&gt;tailscale&lt;/code&gt; call delays every script after it and every subsequent event, and the dispatcher will eventually time out and kill it. Everything in this script is a local socket or D-Bus call. If you're tempted to add a &lt;code&gt;curl&lt;/code&gt; to verify your public IP from inside the dispatcher, put it in a separate systemd unit triggered by the script instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent automation needs a heartbeat.&lt;/strong&gt; The failure mode of a broken dispatcher is identical to the failure mode of no dispatcher: nothing happens and nobody tells you. The &lt;code&gt;NOT PROTECTED&lt;/code&gt; log line is there so a desktop notification hook or a quick &lt;code&gt;journalctl -t wifi-safe -p warning&lt;/code&gt; check has something to grep for. If you already push laptop metrics anywhere, a "seconds since last wifi-safe success on an untrusted network" gauge is a decent alert, with the same don't-page-on-flaps discipline I wrote about in &lt;a href="https://guatulabs.dev/posts/prometheus-alerting-rules-that-don-t-cry-wolf/" rel="noopener noreferrer"&gt;Prometheus alerting rules&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the permission failure on purpose.&lt;/strong&gt; Before you trust this on a trip, &lt;code&gt;chmod 0775&lt;/code&gt; the script, reconnect, and confirm that &lt;code&gt;journalctl -u NetworkManager-dispatcher&lt;/code&gt; tells you something (or doesn't). Knowing what a broken install looks like on your own machine beats discovering it at the gate.&lt;/p&gt;

&lt;p&gt;The bigger shift for me was conceptual. Once the tailnet is the boundary, the Wi-Fi stops being something you evaluate and becomes something you route through. You don't decide whether the airport network is safe. You assume it isn't, every time, and let a fifty-line script make that assumption hold. If you're running Tailscale inside containers rather than on a laptop, the &lt;a href="https://guatulabs.dev/posts/tailscale-kernel-tun-in-unprivileged-lxc-direct-ssh-without-userspace-networking/" rel="noopener noreferrer"&gt;kernel TUN in unprivileged LXC&lt;/a&gt; post covers the same interface-binding idea from the server side, and if you want help applying this kind of zero-trust pattern across a fleet, that's the sort of work I take on through &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tailscale</category>
      <category>networking</category>
      <category>security</category>
      <category>firewalld</category>
    </item>
    <item>
      <title>Claude Headless Sandboxing: Why /tmp Writes Fail in Non-Interactive Mode</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Wed, 30 Sep 2026 16:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/claude-headless-sandboxing-why-tmp-writes-fail-in-non-interactive-mode-de1</link>
      <guid>https://dev.to/futhgar/claude-headless-sandboxing-why-tmp-writes-fail-in-non-interactive-mode-de1</guid>
      <description>&lt;p&gt;A scheduled Claude Code job that writes its report to &lt;code&gt;/tmp/agent_log.md&lt;/code&gt; works every time you run it by hand. Under a systemd timer with &lt;code&gt;claude -p&lt;/code&gt;, it exits 0, the journal shows no errors, and the file is either missing or empty.&lt;/p&gt;

&lt;p&gt;That's the whole symptom. There's no stack trace to search for, which is why this one costs people an afternoon.&lt;/p&gt;

&lt;p&gt;I hit this with my second-brain curator. It's a shell script on a timer that points Claude at a pile of session notes, has it pick out what's worth promoting into long-term memory, and writes a curation report. That's the pipeline behind the &lt;a href="https://guatulabs.dev/posts/self-improving-ai-infrastructure-how-your-homelab-wiki-updates-itself/" rel="noopener noreferrer"&gt;self-updating wiki setup&lt;/a&gt;. The fix itself was small: move the logs out of &lt;code&gt;/tmp&lt;/code&gt; and into &lt;code&gt;$HOME/logs&lt;/code&gt;. The reason the fix works is the interesting part. It says a lot about how headless agents see the filesystem, and about how interactive mode hides the permission model from you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;My mental model was plain Unix. The script runs as my user, &lt;code&gt;/tmp&lt;/code&gt; is world-writable with the sticky bit, so any process running as me can create files there. Claude Code is a process running as me. Therefore Claude Code can write to &lt;code&gt;/tmp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Interactive sessions supported that model. I'd ask Claude to "write the summary to /tmp/curator-test.md" and it would. The file showed up and I moved on.&lt;/p&gt;

&lt;p&gt;The broken version looked something like 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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# curator.sh (the version that silently failed under systemd)&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Review the notes in ./inbox. Write a curation report &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
to /tmp/agent_log.md listing which notes to promote and why."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Glob,Grep,Write"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"curator finished"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run from a terminal, this worked, and the reason is the whole problem.&lt;/p&gt;

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

&lt;p&gt;Two separate mechanisms were stacked on top of each other, and neither of them has anything to do with Unix permissions on &lt;code&gt;/tmp&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Claude Code's permission boundary is the working directory, not your UID
&lt;/h3&gt;

&lt;p&gt;Claude Code doesn't scope file access by what your user account can touch. It scopes it by &lt;strong&gt;working directories&lt;/strong&gt;: the directory you launched from, plus anything you explicitly add. File edits inside that boundary follow your permission mode. File edits outside it need approval.&lt;/p&gt;

&lt;p&gt;In an interactive session you rarely notice, because when Claude tries to write &lt;code&gt;/tmp/curator-test.md&lt;/code&gt; you get a permission prompt, hit "yes," and forget it happened. Maybe you picked "don't ask again" and it went into your local settings, and now you've forgotten that too.&lt;/p&gt;

&lt;p&gt;In print mode (&lt;code&gt;-p&lt;/code&gt;) nobody is there to answer the prompt. The tool call gets denied, and the model is told the write wasn't allowed. Then the model does what models do: it adapts. Sometimes it prints the report to stdout instead. Sometimes it writes a note saying it couldn't save the file. Sometimes it just summarizes what it would have written. The process finishes normally and exits 0.&lt;/p&gt;

&lt;p&gt;So from the script's point of view, nothing failed. An agent that works around a denied permission looks like success to a shell script. That's the gap.&lt;/p&gt;

&lt;p&gt;Putting &lt;code&gt;Write&lt;/code&gt; in &lt;code&gt;--allowedTools&lt;/code&gt; doesn't get you past the boundary either. Allowing the tool isn't the same as allowing the tool to reach any path. The directory scoping still applies.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. systemd has its own opinions about /tmp
&lt;/h3&gt;

&lt;p&gt;Even after fixing the permission boundary, &lt;code&gt;/tmp&lt;/code&gt; under systemd is its own trap. If the service unit (or a drop-in you've forgotten about) sets &lt;code&gt;PrivateTmp=yes&lt;/code&gt;, the service gets a private &lt;code&gt;/tmp&lt;/code&gt; namespace. Your script writes &lt;code&gt;/tmp/agent_log.md&lt;/code&gt;, it lands in a directory like &lt;code&gt;/tmp/systemd-private-&amp;lt;hash&amp;gt;-curator.service-&amp;lt;id&amp;gt;/tmp/&lt;/code&gt;, and when you &lt;code&gt;cat /tmp/agent_log.md&lt;/code&gt; from your own shell afterward, there's nothing there. The file existed. It just existed somewhere you weren't looking, and it gets cleaned up when the service stops.&lt;/p&gt;

&lt;p&gt;Then there's aging. &lt;code&gt;/tmp&lt;/code&gt; is subject to &lt;code&gt;systemd-tmpfiles&lt;/code&gt; cleanup, which I've already been burned by in a different context (see &lt;a href="https://guatulabs.dev/posts/systemd-tmpfiles-and-plex-when-tmp-aging-breaks-eac3-transcoding/" rel="noopener noreferrer"&gt;the Plex EAC3 transcoding post&lt;/a&gt;). Logs you want to inspect next week shouldn't live somewhere designed to forget things.&lt;/p&gt;

&lt;p&gt;Put those together and &lt;code&gt;/tmp&lt;/code&gt; is about the worst place to put the output of an unattended agent. The agent's sandbox doesn't consider it in scope, the service manager may give you a private copy, and the OS will clean it up for you.&lt;/p&gt;

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

&lt;p&gt;The fix has three parts: give the output a permanent home inside a directory Claude is allowed to write, make the calling shell own the log capture, and make denials visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Put output in a directory you explicitly grant
&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;LOG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/logs"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;REPORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;/sb-curator-out-&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.md"&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/second-brain"&lt;/span&gt;   &lt;span class="c"&gt;# working dir = what the agent may edit&lt;/span&gt;

claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Review the notes in ./inbox. Write a curation report &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
to &lt;/span&gt;&lt;span class="nv"&gt;$REPORT&lt;/span&gt;&lt;span class="s2"&gt; listing which notes to promote and why."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-dir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Glob,Grep,Write"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; acceptEdits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cd&lt;/code&gt; first.&lt;/strong&gt; The working directory is the permission boundary. In a systemd unit, the default working directory is &lt;code&gt;/&lt;/code&gt; unless you set &lt;code&gt;WorkingDirectory=&lt;/code&gt;. Launching an agent with &lt;code&gt;/&lt;/code&gt; as its project root is a bad idea for reasons that go well beyond logging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--add-dir&lt;/code&gt;&lt;/strong&gt; extends the boundary to exactly one more directory. That's better than making the boundary bigger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--permission-mode acceptEdits&lt;/code&gt;&lt;/strong&gt; lets file edits inside the granted directories go through without a prompt. Nothing can answer a prompt in headless mode anyway, so the only real question is whether writes inside the boundary are pre-approved or always denied.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To make the grant persistent instead of passing it on every call, put it in the project's &lt;code&gt;.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"additionalDirectories"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"~/logs"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Edit(~/logs/**)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a path-syntax trap here too. In Claude Code permission rules, &lt;code&gt;/logs/**&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; mean an absolute path. A single leading slash is resolved relative to the settings file. For a real absolute path you need &lt;code&gt;//tmp/**&lt;/code&gt; (double slash), or &lt;code&gt;~/&lt;/code&gt; for your home directory. I've seen people write &lt;code&gt;Edit(/tmp/**)&lt;/code&gt;, watch it do nothing, and decide the permission system is broken. It works fine, it just reads the path differently than you'd expect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Let the shell own the log, not the agent
&lt;/h3&gt;

&lt;p&gt;The second change matters more. Stop asking the agent to write its own run log. The agent writes the &lt;em&gt;artifact&lt;/em&gt; (the curation report). The shell captures the &lt;em&gt;transcript&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--add-dir&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Glob,Grep,Write"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; acceptEdits &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;/sb-curator-run-&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.jsonl"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shell redirect or &lt;code&gt;tee&lt;/code&gt; runs as your user with normal Unix semantics. Claude's permission boundary doesn't apply to it at all, because Claude isn't the one writing. Now the transcript always exists, even when the agent's own writes get denied, and that's exactly the case where you need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The stream-json flag change
&lt;/h3&gt;

&lt;p&gt;That &lt;code&gt;--verbose&lt;/code&gt; flag isn't decoration. On current 2.x releases, asking for streaming JSON in print mode without it fails right away:&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;# Fails: stream-json in print mode now requires --verbose&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"summarize ./inbox"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json
&lt;span class="c"&gt;# Error: When using --print, --output-format=stream-json requires --verbose&lt;/span&gt;

&lt;span class="c"&gt;# Works&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"summarize ./inbox"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; stream-json &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one does fail loudly, so at least it's honest. It still breaks any older script that set up clean machine-readable logs before the requirement existed. If your pipeline stopped producing JSONL after an upgrade, check this first. The research notes I keep point to around v2.1.76 as where it started showing up for people, so check against whatever version you've pinned.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make denials visible
&lt;/h3&gt;

&lt;p&gt;This is where headless agents are weak on observability. A denied permission isn't an error exit. You have to go looking for it.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;--output-format json&lt;/code&gt; or &lt;code&gt;stream-json&lt;/code&gt;, the final &lt;code&gt;result&lt;/code&gt; message includes a &lt;code&gt;permission_denials&lt;/code&gt; array. If it isn't empty, the agent tried something it wasn't allowed to do. That's worth an alert, because it means either your grants are wrong or the agent is going somewhere you didn't expect. Both deserve a look.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RUN_LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_DIR&lt;/span&gt;&lt;span class="s2"&gt;/sb-curator-run-&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;.jsonl"&lt;/span&gt;

&lt;span class="c"&gt;# Last line of stream-json is the result message&lt;/span&gt;
&lt;span class="nv"&gt;DENIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RUN_LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="s1"&gt;'.permission_denials | length'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DENIALS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DENIALS&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DENIALS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"curator: &lt;/span&gt;&lt;span class="nv"&gt;$DENIALS&lt;/span&gt;&lt;span class="s2"&gt; permission denial(s), see &lt;/span&gt;&lt;span class="nv"&gt;$RUN_LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
  &lt;span class="nb"&gt;exit &lt;/span&gt;2   &lt;span class="c"&gt;# make systemd mark the unit failed&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Belt and braces: the artifact must exist and be non-empty&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPORT&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"curator: report missing"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the timer unit goes red when the agent quietly works around a denial. Before, it just shrugged and exited 0.&lt;/p&gt;

&lt;h3&gt;
  
  
  The grep exit code detour
&lt;/h3&gt;

&lt;p&gt;Adding checks like these to the curator exposed a second, unrelated bash bug that's common enough to call out. The script counted flagged items in the report:&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;# Broken: grep -c prints "0" AND exits 1 when there are no matches&lt;/span&gt;
&lt;span class="nv"&gt;FLAGGED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ciE&lt;/span&gt; &lt;span class="s1"&gt;'promote|stale'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# On zero matches, FLAGGED becomes "0\n0"&lt;/span&gt;
&lt;span class="c"&gt;# [ "$FLAGGED" -gt 0 ] -&amp;gt; "integer expression expected"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;grep -c&lt;/code&gt; with no matches prints &lt;code&gt;0&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; exits with status 1. The &lt;code&gt;|| echo 0&lt;/code&gt; fallback fires too, so the variable ends up holding two lines. The fix is to swallow the exit code without adding extra output, then default the empty case (for example, when the file doesn't exist):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FLAGGED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ciE&lt;/span&gt; &lt;span class="s1"&gt;'promote|stale'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPORT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;FLAGGED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FLAGGED&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under &lt;code&gt;set -euo pipefail&lt;/code&gt; you need the &lt;code&gt;|| true&lt;/code&gt;. Otherwise a zero-match grep kills the script at the assignment, which is its own confusing failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  The systemd side
&lt;/h3&gt;

&lt;p&gt;Last piece: make the unit explicit instead of relying on defaults.&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;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;curator&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/curator/second-brain&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/curator/bin/curator.sh&lt;/span&gt;
&lt;span class="c"&gt;# Output lives in $HOME/logs, so PrivateTmp can stay on
&lt;/span&gt;&lt;span class="py"&gt;PrivateTmp&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once nothing important lives in &lt;code&gt;/tmp&lt;/code&gt;, you can leave &lt;code&gt;PrivateTmp=yes&lt;/code&gt; on. It's a good hardening default, and it stops being a trap once you aren't depending on &lt;code&gt;/tmp&lt;/code&gt; for state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;This is one specific case of a general problem: &lt;strong&gt;interactive mode is a different runtime environment than headless mode&lt;/strong&gt;, and it looks the same only because a human is quietly approving things. Every "yes" you click in a terminal session is an undocumented dependency. The first time the job runs under cron, systemd, CI, or a Kubernetes CronJob, those dependencies disappear at once.&lt;/p&gt;

&lt;p&gt;Here's when you'll hit it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Promoting a prompt from "works in my terminal" to "runs on a timer."&lt;/strong&gt; This is the most common path. Prototyping is interactive, production is headless, and nothing tells you about the gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving scheduled LLM jobs between backends or hosts.&lt;/strong&gt; When I &lt;a href="https://guatulabs.dev/posts/moving-scheduled-llm-curation-from-cloud-apis-to-local-models/" rel="noopener noreferrer"&gt;moved scheduled curation work toward local models&lt;/a&gt;, the orchestration moved around too. Every move is another chance for the working directory or temp namespace to change under you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any agent that writes to shared scratch space.&lt;/strong&gt; &lt;code&gt;/tmp&lt;/code&gt; feels neutral and safe. For an agent it's outside the project boundary, often namespaced, and actively cleaned up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's how I avoid it now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every headless agent run gets an explicit working directory and explicit extra directories.&lt;/strong&gt; No defaults. If I can't list the paths the agent needs to write, I don't understand the job well enough to schedule it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent writes artifacts; the shell writes logs.&lt;/strong&gt; A transcript captured by &lt;code&gt;tee&lt;/code&gt; survives the agent failing, being denied, or improvising. A log the agent writes itself doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Denials fail the run.&lt;/strong&gt; &lt;code&gt;permission_denials&lt;/code&gt; being non-empty is a non-zero exit. A headless agent that quietly works around a missing permission is worse than one that crashes, because the crash at least shows up in &lt;code&gt;systemctl --failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that the artifact exists, not just the exit code.&lt;/strong&gt; &lt;code&gt;[ -s "$REPORT" ]&lt;/code&gt; is one line and catches most of the ways this goes wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing that matters lives in &lt;code&gt;/tmp&lt;/code&gt;.&lt;/strong&gt; It's for scratch data you're happy to lose, and "the only record of what my agent did last night" isn't that.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's also a memory-design angle. In the &lt;a href="https://guatulabs.dev/posts/six-layer-memory-architecture-for-claude-code/" rel="noopener noreferrer"&gt;six-layer memory setup I run for Claude Code&lt;/a&gt;, the filesystem layer is the one that assumes the agent can write where it's told to. Headless mode tests that assumption hard. It's the same lesson as &lt;a href="https://guatulabs.dev/posts/the-write-policy-is-the-hard-part-promotion-pipelines-for-agent-memory/" rel="noopener noreferrer"&gt;promotion pipelines for agent memory&lt;/a&gt;: deciding &lt;em&gt;where&lt;/em&gt; an agent may write, and proving the write happened, is the hard engineering. The prompt is the easy part.&lt;/p&gt;

&lt;p&gt;The sandbox is doing its job here. Scoping an unattended agent's writes to a known set of directories is exactly what you want in production, and I'd much rather have that default than an agent that can scribble anywhere my UID can reach. The failure mode is that the scoping is invisible until you remove the human, and then the only sign is an empty file. Make the boundaries explicit in the command line, capture everything from outside the agent, and treat "the agent adapted around a denial" as the failure it is.&lt;/p&gt;

&lt;p&gt;If you're taking agent pipelines from prototype to unattended production and want a second set of eyes on the operational side, that's the kind of work I &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;help teams with&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>claudecode</category>
      <category>automation</category>
      <category>systemd</category>
    </item>
    <item>
      <title>Etcd Defragmentation: Reclaiming Space Without Cluster Disruption</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 28 Sep 2026 16:15:49 +0000</pubDate>
      <link>https://dev.to/futhgar/etcd-defragmentation-reclaiming-space-without-cluster-disruption-16cg</link>
      <guid>https://dev.to/futhgar/etcd-defragmentation-reclaiming-space-without-cluster-disruption-16cg</guid>
      <description>&lt;p&gt;My etcd database was 508 MB on disk and only 84 MB of it held live data. The other 400-odd megabytes were free pages that etcd had no intention of handing back to the filesystem. Defragmenting brought it back down to 84 MB. That part took seconds. Doing it without the control plane noticing took more care.&lt;/p&gt;

&lt;p&gt;If you run your own control plane (kubeadm on bare metal, Proxmox VMs, anything where nobody else looks after etcd for you), this is your job. Managed Kubernetes providers do it in the background. On a homelab or on-prem cluster, nobody does it unless you set it up, and nothing tells you it's needed until you hit a quota alarm or start seeing odd latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why etcd grows and never shrinks
&lt;/h2&gt;

&lt;p&gt;etcd stores data in bbolt, a B+tree in a single memory-mapped file. Every write to a Kubernetes object creates a new revision. Lease renewals, Endpoints updates, Event objects and controller status patches all add revisions. The kube-apiserver compacts old revisions every 5 minutes by default (&lt;code&gt;--etcd-compaction-interval&lt;/code&gt;), so history doesn't pile up forever.&lt;/p&gt;

&lt;p&gt;The catch is that compaction only marks pages as free &lt;em&gt;inside&lt;/em&gt; the bbolt file. The file stays the same size. bbolt reuses those free pages for new writes, so the file doesn't grow without limit, but after a burst of churn (a big rollout, a namespace deletion, a controller stuck in a status-update loop) the file keeps its high-water mark.&lt;/p&gt;

&lt;p&gt;That causes three problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The quota counts file size, not live data.&lt;/strong&gt; &lt;code&gt;--quota-backend-bytes&lt;/code&gt; defaults to 2 GiB. When the file hits that limit, etcd raises a &lt;code&gt;NOSPACE&lt;/code&gt; alarm and goes read-only for writes. The apiserver starts rejecting creates and updates, even if 80% of the file is empty pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots copy the whole file.&lt;/strong&gt; A &lt;code&gt;snapshot save&lt;/code&gt; of a fragmented database writes a fragmented snapshot, so bloat gets multiplied by however many backups you keep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bigger mmap, slower startup.&lt;/strong&gt; A member that has to restart and load a bloated database takes longer to rejoin.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Defragmentation rewrites the bbolt file with only the live pages and releases the rest back to the filesystem. It's the only way to shrink the file short of restoring from a snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know when it's actually needed
&lt;/h2&gt;

&lt;p&gt;Don't defrag on a timer just because you can. Defrag when the gap between on-disk size and in-use size is large. &lt;code&gt;etcdctl endpoint status&lt;/code&gt; reports both in its JSON output:&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;export &lt;/span&gt;&lt;span class="nv"&gt;ETCDCTL_API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ETCDCTL_CACERT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/kubernetes/pki/etcd/ca.crt
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ETCDCTL_CERT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/kubernetes/pki/etcd/server.crt
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ETCDCTL_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/kubernetes/pki/etcd/server.key
&lt;span class="nv"&gt;ENDPOINTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://10.0.0.11:2379,https://10.0.0.12:2379,https://10.0.0.13:2379"&lt;/span&gt;

etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; endpoint status &lt;span class="nt"&gt;-w&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] |
  "\(.Endpoint)  total=\(.Status.dbSize / 1048576 | floor)MiB  " +
  "inuse=\(.Status.dbSizeInUse / 1048576 | floor)MiB  " +
  "ratio=\((.Status.dbSizeInUse / .Status.dbSize * 100) | floor)%  " +
  "leader=\(.Status.leader == .Status.header.member_id)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before my defrag, the output looked like this (endpoints anonymized):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://10.0.0.11:2379  total=508MiB  inuse=84MiB  ratio=16%  leader=true
https://10.0.0.12:2379  total=497MiB  inuse=84MiB  ratio=16%  leader=false
https://10.0.0.13:2379  total=502MiB  inuse=84MiB  ratio=16%  leader=false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The in-use size is almost identical across members because they all hold the same data. The on-disk size differs a bit because each member's bbolt file fragments on its own. A ratio of 16% means five-sixths of the file is dead space.&lt;/p&gt;

&lt;p&gt;My rule of thumb: act when the in-use ratio drops below 50% &lt;strong&gt;and&lt;/strong&gt; the total is large enough to matter (more than about 100 MiB). A 40 MiB database at 45% isn't worth a maintenance window. The research brief's version of this check used "DB size vs. total keys", which works as a rough signal, but &lt;code&gt;dbSizeInUse&lt;/code&gt; measures the thing you care about directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-disruption part: one member at a time, followers first
&lt;/h2&gt;

&lt;p&gt;Defragmentation blocks the member it's running on. While bbolt rewrites the file, that member can't serve reads or writes. On a small database this takes a second or two. On a multi-gigabyte one it can take much longer. The etcd and Kubernetes docs both say the same thing: defrag one member at a time, and never touch more than one at once.&lt;/p&gt;

&lt;p&gt;Two things follow from that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't use &lt;code&gt;etcdctl defrag --cluster&lt;/code&gt;.&lt;/strong&gt; It goes through every member in the member list back to back, with no pause and no health check between them. If one member is slow to recover, the next one is already blocked, and in a three-member cluster two members down means no quorum.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do the leader last.&lt;/strong&gt; A defrag on a follower barely matters: the leader keeps committing with the other follower, and the defragged follower catches up afterwards. A long defrag on the leader can stall it past the followers' election timeout (1000 ms by default). That triggers an election, and every in-flight apiserver request to etcd waits on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the script I run from a control plane node. It finds the leader, defrags each follower, checks health after each one, and handles the leader last:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;source&lt;/span&gt; /etc/etcd-maint.env   &lt;span class="c"&gt;# exports ETCDCTL_* certs and ENDPOINTS&lt;/span&gt;

&lt;span class="nv"&gt;leader&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; endpoint status &lt;span class="nt"&gt;-w&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.Status.leader == .Status.header.member_id) | .Endpoint'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$leader&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;echo&lt;/span&gt; &lt;span class="s2"&gt;"no leader found, aborting"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;followers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vxF&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$leader&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;ep &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$followers&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$leader&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"== defrag &lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ep&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--command-timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120s defrag
  &lt;span class="c"&gt;# Every member must be healthy before we touch the next one&lt;/span&gt;
  etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; endpoint health
  &lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done

&lt;/span&gt;etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; alarm list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some notes on the choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--command-timeout=120s&lt;/code&gt; matters. The default is 5 seconds, and on a large database the client gives up while the server is still defragmenting. You get an error, the defrag finishes anyway, and you're left unsure what state things are in.&lt;/li&gt;
&lt;li&gt;The health check runs against &lt;strong&gt;all&lt;/strong&gt; endpoints, not just the one you defragged. Under &lt;code&gt;set -e&lt;/code&gt;, one unhealthy member stops the script before the next defrag starts. That's the whole safety mechanism, so don't remove it.&lt;/li&gt;
&lt;li&gt;The 30-second sleep lets the defragged member catch up on raft entries it missed. It isn't strictly required, but it's cheap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;alarm list&lt;/code&gt; at the end catches a leftover &lt;code&gt;NOSPACE&lt;/code&gt; alarm (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a kubeadm cluster, if you don't want to install &lt;code&gt;etcdctl&lt;/code&gt; on the host, you can &lt;code&gt;kubectl exec&lt;/code&gt; into each &lt;code&gt;etcd-&amp;lt;node&amp;gt;&lt;/code&gt; static pod and run the same commands. The certificate paths are the same because kubeadm mounts &lt;code&gt;/etc/kubernetes/pki/etcd&lt;/code&gt; into the pod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the leader: move it or accept the election
&lt;/h2&gt;

&lt;p&gt;If the leader stalls, leadership can move somewhere else, and that's what happened in my maintenance window. After I defragged the leader, a different node held leadership and the raft term had gone up. Nothing broke. The apiserver retried and the cluster stayed healthy. It still made me want to check afterwards whether the cluster had settled or was flapping.&lt;/p&gt;

&lt;p&gt;There's a cleaner option: move leadership off the node yourself, then defrag it as a follower.&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;# Pick a follower that's already been defragged as the new leader&lt;/span&gt;
&lt;span class="nv"&gt;new_leader_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://10.0.0.12:2379"&lt;/span&gt; endpoint status &lt;span class="nt"&gt;-w&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[0].Status.header.member_id | tostring'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# move-leader has to be sent to the current leader's endpoint&lt;/span&gt;
etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$leader&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; move-leader &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%x'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$new_leader_id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;move-leader&lt;/code&gt; takes the member ID in hex, which is the format &lt;code&gt;etcdctl member list&lt;/code&gt; prints. The JSON output gives it in decimal, hence the &lt;code&gt;printf&lt;/code&gt;. There's also a jq trap here: member IDs are 64-bit, and older jq versions convert large integers to doubles and lose precision. If the hex ID looks wrong, take it straight from &lt;code&gt;etcdctl member list -w table&lt;/code&gt; rather than trusting the conversion.&lt;/p&gt;

&lt;p&gt;Once leadership has moved, the old leader is a follower, and your defrag loop treats it like any other follower. There's no surprise election.&lt;/p&gt;

&lt;p&gt;Whichever route you take, confirm the cluster is stable afterwards:&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;# Run twice, a minute apart. Same leader and same raft term = settled.&lt;/span&gt;
etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; endpoint status &lt;span class="nt"&gt;-w&lt;/span&gt; json | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] |
  "\(.Endpoint) leader=\(.Status.leader == .Status.header.member_id) term=\(.Status.raftTerm)"'&lt;/span&gt;

kubectl get &lt;span class="nt"&gt;--raw&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'/readyz?verbose'&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;etcd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the raft term keeps rising between the two runs, leadership is still bouncing. Stop and investigate (disk latency is the usual cause) before you blame the defrag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NOSPACE alarm doesn't clear itself
&lt;/h2&gt;

&lt;p&gt;If you're defragging because you already hit the quota, there's one more step. Defragmentation frees the space, but the &lt;code&gt;NOSPACE&lt;/code&gt; alarm stays raised until you disarm it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; alarm list
&lt;span class="c"&gt;# memberID:1234567890 alarm:NOSPACE&lt;/span&gt;

etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; alarm disarm
etcdctl &lt;span class="nt"&gt;--endpoints&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINTS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; alarm list   &lt;span class="c"&gt;# should print nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until you disarm it, etcd keeps refusing writes even though the file is now a fraction of its old size. This is the step people miss when they defrag during an outage and then wonder why &lt;code&gt;kubectl apply&lt;/code&gt; still fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The side effect nobody mentions: snapshot sprawl
&lt;/h2&gt;

&lt;p&gt;This is the part the research brief called the "total cost of maintenance", and it's the lesson I'd pass on first. You should take a snapshot before any defrag. Defrag has been reliable in my experience, but "reliable" isn't a backup strategy. On a control plane with a small boot disk (mine are in the 30 GB range), those snapshots add up quickly, especially snapshots of a fragmented database. You fix etcd bloat and get &lt;code&gt;DiskPressure&lt;/code&gt; on the control plane node instead, and the kubelet starts evicting things.&lt;/p&gt;

&lt;p&gt;The fix is to prune snapshots automatically, on the same schedule that creates them. A systemd timer works well:&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="c"&gt;# /etc/systemd/system/etcd-snapshot.service
&lt;/span&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;etcd snapshot with retention&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/etcd-maint.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/sh -c 'etcdctl --endpoints=https://127.0.0.1:2379 &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;snapshot save /var/backups/etcd/etcd-$(date +%%Y%%m%%dT%%H%%M).db'&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPost&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/find /var/backups/etcd -name "etcd-*.db" -mtime +3 -delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/etcd-snapshot.timer
&lt;/span&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;Daily etcd snapshot&lt;/span&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:15: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;15m&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;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable it with &lt;code&gt;systemctl enable --now etcd-snapshot.timer&lt;/code&gt;. Three days of retention is enough locally because the snapshots also go off-box. A snapshot that only lives on the control plane disk doesn't protect you if you lose that disk. In my setup cluster-level backups go to object storage (the &lt;a href="https://guatulabs.dev/posts/velero-minio-kubernetes-backup-strategy-for-bare-metal/" rel="noopener noreferrer"&gt;Velero + MinIO setup&lt;/a&gt; covers that side), and the control plane VMs themselves are backed up at the hypervisor level.&lt;/p&gt;

&lt;p&gt;A useful side effect: defragging before your snapshot window makes every snapshot after it smaller, because the snapshot size follows the bbolt file size. In my case that's 84 MB per snapshot instead of 508 MB, which changes how many days of retention a small disk can hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alert on the ratio, not the size
&lt;/h2&gt;

&lt;p&gt;I found my bloat after the fact, while chasing pod restarts that looked unrelated. Nothing had alerted. That's the observability gap: etcd fragmentation doesn't show up as a failing health check, just as a number drifting upward on a metric nobody watches.&lt;/p&gt;

&lt;p&gt;etcd exposes both sizes as Prometheus metrics. The etcd-mixin (bundled with kube-prometheus) already ships a rule for this, and if you're writing your own, it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;etcd-maintenance&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EtcdDatabaseHighFragmentation&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;(last_over_time(etcd_mvcc_db_total_size_in_use_in_bytes[5m])&lt;/span&gt;
            &lt;span class="s"&gt;/ last_over_time(etcd_mvcc_db_total_size_in_bytes[5m])) &amp;lt; 0.5&lt;/span&gt;
          &lt;span class="s"&gt;and etcd_mvcc_db_total_size_in_use_in_bytes &amp;gt; 104857600&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;etcd&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;50%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pages;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;schedule&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;defrag"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The size guard (100 MiB in use) keeps the alert quiet on small databases, where a bad ratio doesn't matter. The one-hour &lt;code&gt;for&lt;/code&gt; rides out short-lived churn during rollouts. Severity is warning, not critical: this is "schedule maintenance this week", not "wake someone up". Pair it with a separate rule on &lt;code&gt;etcd_mvcc_db_total_size_in_bytes&lt;/code&gt; approaching &lt;code&gt;etcd_server_quota_backend_bytes&lt;/code&gt;, and give that one a higher severity, because it means writes are about to stop. I wrote more about keeping rules like these actionable in &lt;a href="https://guatulabs.dev/posts/prometheus-alerting-rules-that-don-t-cry-wolf/" rel="noopener noreferrer"&gt;Prometheus Alerting Rules That Don't Cry Wolf&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your control plane is scraped over HTTPS with client certs (kubeadm's default puts metrics on &lt;code&gt;127.0.0.1:2381&lt;/code&gt; over plain HTTP), check that Prometheus can reach it before you trust the absence of an alert. A missing metric doesn't fire an alert, which is how the gap stays open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and alternatives I considered
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Offline defrag with &lt;code&gt;etcdutl&lt;/code&gt;.&lt;/strong&gt; If a member is stopped (for example during a node rebuild), &lt;code&gt;etcdutl defrag --data-dir /var/lib/etcd&lt;/code&gt; defragments the files directly without a running server. It's fine for a member that's down anyway. It's the wrong tool for a live cluster, because stopping a member to defrag it offline costs more availability than an online defrag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defrag doesn't compact.&lt;/strong&gt; If compaction isn't running (a custom apiserver flag, or a standalone etcd without &lt;code&gt;--auto-compaction-retention&lt;/code&gt;), defrag reclaims almost nothing, because the old revisions are still live data. Check that &lt;code&gt;dbSizeInUse&lt;/code&gt; is reasonable first. If in-use is huge, the problem is compaction or a runaway writer, not fragmentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find the churn source.&lt;/strong&gt; A database that re-fragments within days means something is writing constantly. Event objects with long TTLs, a controller hot-looping on status updates, and oversized ConfigMaps rewritten on every reconcile are the usual suspects. Defrag treats the symptom. &lt;code&gt;etcd_debugging_mvcc_keys_total&lt;/code&gt; and apiserver request metrics grouped by resource will point you at the cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk latency makes everything worse.&lt;/strong&gt; Defrag writes a whole new copy of the database. On slow or shared storage, that write burst can push fsync latency past what raft tolerates, and then you get elections you didn't ask for. If your control plane VMs share a disk with noisy neighbors, run defrags in quiet hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled automatic defrag.&lt;/strong&gt; Some people run a nightly defrag on cron. I prefer letting the alert drive it: defrag is cheap but not free, and running it unconditionally hides the churn signal you'd otherwise notice. If you do automate it, use the follower-first script with health gates, never &lt;code&gt;--cluster&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Defragmentation is safe when you treat it as a rolling operation: snapshot first, one member at a time, followers before the leader (or move leadership off first), health-gate every step, disarm any alarm, and then check that leadership has settled. The command itself is the easy part. The work is in the process around it, especially snapshot retention on small control plane disks, which is how cleaning up one problem turns into the next one.&lt;/p&gt;

&lt;p&gt;Use this when the in-use ratio drops below half on a database big enough to matter, when you've hit &lt;code&gt;NOSPACE&lt;/code&gt;, or before you take a snapshot you plan to keep for a long time. Set up the fragmentation alert so you hear about it from a warning rather than from a failing &lt;code&gt;kubectl apply&lt;/code&gt;. If you're running control planes like this and want a second opinion on the operational side, that's the kind of work I do through &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs&lt;/a&gt;. For more on how the control plane VMs fit into the wider cluster, see &lt;a href="https://guatulabs.dev/posts/building-production-homelab/" rel="noopener noreferrer"&gt;Building a Production Homelab&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>etcd</category>
      <category>kubernetes</category>
      <category>controlplane</category>
      <category>defragmentation</category>
    </item>
    <item>
      <title>Blog Pipeline Fence Corruption: How a Naive Sed Rule Swallowed Markdown Code Blocks</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Mon, 31 Aug 2026 22:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/blog-pipeline-fence-corruption-how-a-naive-sed-rule-swallowed-markdown-code-blocks-43p4</link>
      <guid>https://dev.to/futhgar/blog-pipeline-fence-corruption-how-a-naive-sed-rule-swallowed-markdown-code-blocks-43p4</guid>
      <description>&lt;p&gt;A draft came out of my blog generation pipeline with the frontmatter intact, the title correct, the word count in range, and roughly 1,400 words of prose rendered inside a single syntax-highlighted code block. Everything after the first &lt;code&gt;bash&lt;/code&gt; example was gray monospace on a dark background. The build passed. The exit code was 0.&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;sed&lt;/code&gt; expression caused it, and it had been sitting in the generation script since the pipeline moved from a local model to a cloud backend. It did exactly what it was told to do, which was the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;The setup is boring and probably familiar. A shell script calls a model in non-interactive mode, captures stdout, and writes the result to &lt;code&gt;src/content/posts/&amp;lt;slug&amp;gt;.md&lt;/code&gt;. Astro picks it up from the content collection, Zod validates the frontmatter, and &lt;code&gt;npm run build&lt;/code&gt; renders it.&lt;/p&gt;

&lt;p&gt;Models like to wrap their output. You ask for "a complete markdown file, output only the markdown," and a meaningful fraction of the time you get back something shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="err"&gt;```&lt;/span&gt;&lt;span class="s"&gt;markdown&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Whatever"&lt;/span&gt;

&lt;span class="s"&gt;Post body here.&lt;/span&gt;
&lt;span class="err"&gt;```&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That outer fence is not part of the document. Write it to disk as-is and Astro sees a code block where the frontmatter should be, Zod finds no &lt;code&gt;title&lt;/code&gt;, and the build fails loudly. Loud failures are fine. You fix them and move on.&lt;/p&gt;

&lt;p&gt;So the pipeline stripped the wrapper before writing. That was the intent, and the expected outcome was a clean file with the outer fence gone and every internal code block untouched.&lt;/p&gt;

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

&lt;p&gt;Here is the rule, as it existed in the generation script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The "cleanup" that caused the problem&lt;/span&gt;
claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^```markdown$//; s/^```$//'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the second expression on its own: &lt;code&gt;s/^&lt;/code&gt;`&lt;code&gt;$//&lt;/code&gt;. It replaces any line consisting of exactly three backticks with an empty line. Every line. Not the last one, not the wrapper's closer specifically, all of them.&lt;/p&gt;

&lt;p&gt;Markdown fences are a toggle, not a container. An opening fence with an info string (&lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;/code&gt;bash `) turns code mode on. A bare closing fence turns it off. Delete every bare closing fence in a document and you have deleted every "off" switch while keeping every "on" switch.&lt;/p&gt;

&lt;p&gt;Corruption follows deterministically from there. The first &lt;code&gt;&lt;/code&gt;`&lt;code&gt;bash&lt;/code&gt; in the post opens a code block, and that block never closes. Every heading, paragraph, table, and link after it becomes code block content. The renderer is behaving correctly. It was handed a document that says, in effect, "the rest of this file is bash."&lt;/p&gt;

&lt;p&gt;Nothing about the file is malformed by CommonMark rules, which is why it looked like corrupted prose rather than a syntax error. An unclosed fence at end-of-document is legal and closes implicitly. The parser doesn't complain, the markdown linter doesn't complain, and Astro renders it happily into a page nobody would read past the first screenful.&lt;/p&gt;

&lt;p&gt;That first expression, &lt;code&gt;s/^&lt;/code&gt;&lt;code&gt;&lt;/code&gt;markdown$//&lt;code&gt;, carried a smaller version of the same flaw. It emptied any line that was exactly &lt;/code&gt; &lt;code&gt;&lt;/code&gt;&lt;code&gt;markdown&lt;/code&gt;, which is a legitimate thing to write in a post about markdown tooling. Delete that opener and you're left with an orphaned closer, which then opens a code block for the remainder of the file. Same failure, inverted.&lt;/p&gt;

&lt;p&gt;Two things kept this from being obvious for longer than I'd like.&lt;/p&gt;

&lt;p&gt;Local models mostly didn't trigger it. The Ollama-backed path had different post-processing and rarely emitted the &lt;code&gt;&lt;/code&gt;&lt;code&gt;&lt;/code&gt;markdown &lt;code&gt; wrapper in the first place, which is the kind of behavioral difference you don't discover until you [move a scheduled generation job between backends](https://guatulabs.dev/posts/moving-scheduled-llm-curation-from-cloud-apis-to-local-models/). So the &lt;/code&gt;sed` rule was written for a problem that mostly existed on one branch of the pipeline, and it damaged output on both.&lt;/p&gt;

&lt;p&gt;Beyond that, nothing in the pipeline had an opinion about document structure. It checked word count. It checked frontmatter fields. It ran an anti-slop word filter. Every one of those checks passes cleanly on a file whose entire body is trapped inside a code block, because none of them parse the document as a document. When the only signal your automation emits is an exit code, you get told "success" for an entire class of failures that never touches the exit code. Structural validity has to be asserted explicitly, the same way you assert that a Kubernetes manifest is valid &lt;a href="https://guatulabs.dev/posts/kubernetes-manifest-validation-catching-errors-before-merge/" rel="noopener noreferrer"&gt;before it merges&lt;/a&gt; rather than after it's applied to a live cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part one: stop using substitution for a positional problem
&lt;/h2&gt;

&lt;p&gt;The wrapper is a positional artifact. It lives on line 1 and on the final non-empty line, and nowhere else. A substitution rule anchored with &lt;code&gt;^...$&lt;/code&gt; has no concept of position, so it was the wrong tool from the first commit.&lt;/p&gt;

&lt;p&gt;What replaced it matches on a signature rather than a pattern, and touches exactly two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;unwrap_markdown_fence&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;file&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="nb"&gt;local &lt;/span&gt;l1 l2 last lastno fences

  &lt;span class="nv"&gt;l1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'1p'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;l2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'2p'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;lastno&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;lastno&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;p"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="c"&gt;# Signature of a whole-document wrapper:&lt;/span&gt;
  &lt;span class="c"&gt;# line 1 is a fence, line 2 opens frontmatter, last non-empty line closes it.&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$l1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="s1"&gt;'```markdown'&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s1"&gt;'```md'&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s1"&gt;'```'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0 &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$l2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'---'&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'```'&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0

  &lt;span class="c"&gt;# Even fence count means the wrapper pairs cleanly with itself.&lt;/span&gt;
  &lt;span class="nv"&gt;fences&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^```'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;((&lt;/span&gt; fences % 2 &lt;span class="o"&gt;==&lt;/span&gt; 0 &lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0

  &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"1d;&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;lastno&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking line 2 for &lt;code&gt;---&lt;/code&gt; is what makes this safe. A wrapped document always has the frontmatter delimiter immediately after the opener, because that's how every post in the collection starts. A legitimate post that happens to open with a code fence will not have &lt;code&gt;---&lt;/code&gt; sitting on line 2.&lt;/p&gt;

&lt;p&gt;Every guard clause returns 0 instead of failing. If the signature doesn't match, the function does nothing and the file passes through untouched. A cleanup step should be inert when it isn't confident, not aggressive. The old rule was the opposite: maximally confident, zero context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part two: assert structure, don't count it
&lt;/h2&gt;

&lt;p&gt;Unwrapping correctly is necessary but not sufficient, because the model itself can drop a closing fence. Any repair you write can also have bugs. So the pipeline needed something that reads the finished file and asserts the body is actually prose.&lt;/p&gt;

&lt;p&gt;My first instinct was to count fences and check for an even number. That check is close to worthless. A document with a missing closer and a stray opener has an even count and is still broken, and a post that legitimately uses four-backtick fences to demonstrate nested markdown throws the count off entirely. Counting tells you about symbols. You need to know about state.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;check-fences.sh&lt;/code&gt; walks the file, tracks whether it's inside a code block, and looks for content that has no business being there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;file&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="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'
  /^```/ {
    if (!in_code) { in_code=1; open_line=NR } else { in_code=0 }
    next
  }
  # Markdown structures that should never appear inside a fence
  in_code &amp;amp;&amp;amp; /^#{1,6} / {
    printf "line %d: heading inside code block opened at line %d\n", NR, open_line
    bad=1
  }
  in_code &amp;amp;&amp;amp; /^\|.*\|$/ {
    printf "line %d: table row inside code block opened at line %d\n", NR, open_line
    bad=1
  }
  END {
    if (in_code) {
      printf "unclosed fence opened at line %d (EOF)\n", open_line
      bad=1
    }
    exit bad
  }
'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heading detection is what actually catches the &lt;code&gt;sed&lt;/code&gt; bug. A swallowed document always contains &lt;code&gt;## Something&lt;/code&gt; inside the runaway block, because posts have sections. Table rows catch the same failure in posts built around comparison tables. The end-of-file check catches the simpler case where the model just forgot a closer.&lt;/p&gt;

&lt;p&gt;False positives exist and I decided to live with them. A shell script demonstrating comment syntax can contain a line starting with &lt;code&gt;#&lt;/code&gt;, but the awk pattern requires a space after one to six hashes at column zero, and code comments in the posts I generate rarely look like a markdown heading with a capitalized sentence after it. When it does misfire, the failure mode is a rejected draft rather than a published page of gray monospace, and I'll take that trade every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, part three: repair before you reject
&lt;/h2&gt;

&lt;p&gt;A validator that only says no creates its own failure mode. The instinct when a generation step fails is to retry, but retrying an expensive model call to fix a mechanical, deterministic defect is the wrong shape of solution. Worse, the regenerated draft is a different draft. You've thrown away good prose to fix a missing three-character line, and the new attempt can trip a different gate entirely.&lt;/p&gt;

&lt;p&gt;Corruption from a missing closer has exactly one correct repair, so &lt;code&gt;repair-fences.sh&lt;/code&gt; performs it directly and runs before the check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;file&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="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'
  /^```/ { in_code = !in_code; print; next }
  # A heading inside a code block means the previous fence never closed.
  in_code &amp;amp;&amp;amp; /^#{1,6} / { print "```"; in_code=0; print; next }
  { print }
  END { if (in_code) print "```" }
'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ordering matters here. Repair runs first, the checker runs second, and the checker is the gate. If repair does its job, the check passes and the draft survives. If repair produces something still structurally broken, the check fails and the pipeline stops with a specific line number instead of a shrug. Repair is allowed to be optimistic precisely because it isn't the thing making the final call. That separation of "attempt a fix" from "verify the fix" is the same discipline that makes plan-and-apply workflows trustworthy in &lt;a href="https://guatulabs.dev/posts/automating-infrastructure-with-opentofu-and-github-actions/" rel="noopener noreferrer"&gt;infrastructure automation&lt;/a&gt;, and it applies just as well to a text pipeline.&lt;/p&gt;

&lt;p&gt;One caveat worth stating plainly: a repair step that mutates content is a liability if you can't see what it did. Mine writes a line to the run log whenever it changes the file, including the line number where it inserted a closer. Silent auto-repair is how you end up debugging a bug that a script already "fixed" three stages earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the gates fight each other
&lt;/h2&gt;

&lt;p&gt;Content quality gates and structural gates want different things, and stacking them naively produces a pipeline that rejects everything.&lt;/p&gt;

&lt;p&gt;My anti-slop checker is a hard gate. Banned words, banned phrases, an em-dash budget, a rule against consecutive paragraphs opening with the same word. It's deterministic and it's strict, which is the point. But a hard gate on prose quality combined with a hard gate on structure means a draft has to clear both in a single generation, and the retry cost is a full regeneration.&lt;/p&gt;

&lt;p&gt;The ordering that works for me: run mechanical repairs first (fence balance, wrapper stripping, frontmatter normalization), then run the structural gate, then run the quality gate, and only regenerate on quality failures. Mechanical problems get fixed in place because they have one right answer. Prose problems get sent back to the model with the specific violations included in the retry prompt, which turns a blind retry into a targeted revision. Feeding the checker's own output back into the next attempt is what makes the loop converge instead of thrashing, and it's the same feedback-shaped design that keeps &lt;a href="https://guatulabs.dev/posts/six-layer-memory-architecture-for-claude-code/" rel="noopener noreferrer"&gt;multi-layer agent memory systems&lt;/a&gt; from degrading over repeated passes.&lt;/p&gt;

&lt;p&gt;Anything a script can repair deterministically should never reach the model twice. That's the rule I landed on, and the fence bug is the clearest example of why: a missing &lt;code&gt;&lt;/code&gt;`&lt;code&gt;&lt;/code&gt; cost an entire draft and a model call when a five-line awk program fixes it in milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters beyond one blog
&lt;/h2&gt;

&lt;p&gt;Text transformation with line-anchored regex is a trap any time the text has stateful syntax, and markdown is full of it. Fences toggle. List indentation nests. Frontmatter delimiters and horizontal rules are the same three characters in different positions. HTML comments and YAML block scalars both swallow content until a terminator. A &lt;code&gt;sed&lt;/code&gt; expression sees a line. Your document has a grammar, and lines don't know what mode they're in.&lt;/p&gt;

&lt;p&gt;Two habits keep this from biting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match on position or signature, not on pattern, when the artifact is positional.&lt;/strong&gt; If what you're stripping is "line 1 and the last line," write a rule that says line 1 and the last line. Anchors are not position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert the shape of the output, not just the presence of the output.&lt;/strong&gt; Word count, file existence, and frontmatter fields all confirm that &lt;em&gt;something&lt;/em&gt; landed on disk. None of them confirm it's a document. A structural check that understands the format is the only thing that closes the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader pattern generalizes past blog tooling. Any pipeline that pipes model output through shell transformations and into a build system has this exposure, and the failure is quiet by construction: exit code 0, valid file, wrong document. If you're building automation where a model's output feeds downstream systems and you'd rather not discover the corruption from a reader, that validation layer is worth designing up front. It's a chunk of what I end up building for people in &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;agent and pipeline work&lt;/a&gt;, and it's almost always cheaper than the cleanup.&lt;/p&gt;

&lt;p&gt;What I'd do differently: I'd have written &lt;code&gt;check-fences.sh&lt;/code&gt; before I wrote any transformation step at all. The validator is 20 lines of awk. The &lt;code&gt;sed&lt;/code&gt; rule it protects against took a week of published output to notice, and noticing required a human looking at a rendered page. Automation that can't tell you it's broken isn't automation, it's a faster way to be wrong.&lt;/p&gt;

</description>
      <category>markdown</category>
      <category>shellscripting</category>
      <category>automation</category>
      <category>llmtooling</category>
    </item>
    <item>
      <title>Tailscale Kernel TUN in Unprivileged LXC: Direct SSH Without Userspace Networking</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 22 Aug 2026 06:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/tailscale-kernel-tun-in-unprivileged-lxc-direct-ssh-without-userspace-networking-18la</link>
      <guid>https://dev.to/futhgar/tailscale-kernel-tun-in-unprivileged-lxc-direct-ssh-without-userspace-networking-18la</guid>
      <description>&lt;p&gt;&lt;code&gt;tailscale up --tun=userspace-networking&lt;/code&gt; gets you a green dot in the admin console and almost nothing else. The node appears in your tailnet, &lt;code&gt;tailscale status&lt;/code&gt; looks healthy, and then you try to SSH into that container from your laptop and the connection hangs until TCP gives up. Two lines in the LXC config file fix it, and the container stays unprivileged.&lt;/p&gt;

&lt;p&gt;That's the whole post, really. But those two lines only make sense once you understand why every guide pushes you toward userspace mode in the first place, and what you're giving up by staying there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should care
&lt;/h2&gt;

&lt;p&gt;Anyone running services in unprivileged LXC containers on Proxmox who wants those containers to be real tailnet members with their own &lt;code&gt;100.64.0.0/10&lt;/code&gt; address. Not reachable &lt;em&gt;through&lt;/em&gt; something else. Reachable directly, over WireGuard, with a kernel network interface that &lt;code&gt;ip addr&lt;/code&gt; can see.&lt;/p&gt;

&lt;p&gt;If you're already routing everything through a subnet router, you have a working setup and this is an optional upgrade. I covered that pattern in &lt;a href="https://guatulabs.dev/posts/tailscale-subnet-router-remote-access-without-traditional-vpn/" rel="noopener noreferrer"&gt;Tailscale Subnet Routers&lt;/a&gt;. Treat this as the next rung on the ladder: instead of one node advertising routes on behalf of everyone else, each container carries its own identity, its own ACL surface, and its own direct path to peers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What userspace networking actually costs you
&lt;/h2&gt;

&lt;p&gt;Every LXC-and-Tailscale guide I've read lands on the same instruction: pass &lt;code&gt;--tun=userspace-networking&lt;/code&gt; and move on. It works because it sidesteps the problem entirely. Rather than asking the kernel for a TUN device, tailscaled runs a userspace TCP/IP stack (gVisor's netstack) inside its own process and never opens &lt;code&gt;/dev/net/tun&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those costs stay invisible until you trip over one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outbound traffic needs a proxy.&lt;/strong&gt; In userspace mode, tailscaled exposes SOCKS5 and HTTP proxies on a local port. Nothing on the system routes to &lt;code&gt;100.64.0.0/10&lt;/code&gt; automatically, because there is no interface and no route. Every client has to be told about the proxy:&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;# userspace mode: this is the only way out&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALL_PROXY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;socks5://localhost:1055/
curl http://100.64.0.20:8080/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss that env var in a systemd unit, a cron job, or a nested container, and the traffic silently takes the normal default route instead. No error. It just goes somewhere else, which is the worst failure mode a network can have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inbound to a normal daemon doesn't happen.&lt;/strong&gt; Your &lt;code&gt;sshd&lt;/code&gt; binds &lt;code&gt;0.0.0.0:22&lt;/code&gt; on the container's LAN interface. Packets arriving over the tailnet terminate inside tailscaled's netstack, and there's no path from netstack to a socket the kernel owns unless you build one explicitly with &lt;code&gt;tailscale serve&lt;/code&gt;, or you switch to Tailscale SSH where tailscaled itself is the SSH server. Tailscale SSH genuinely works in userspace mode, which is why plenty of people never notice the limitation. But that's tailscaled's SSH implementation, not OpenSSH. If you care about host key pinning, &lt;code&gt;authorized_keys&lt;/code&gt; command restrictions, &lt;code&gt;Match&lt;/code&gt; blocks, or an &lt;code&gt;sshd_config&lt;/code&gt; you've tuned, you now maintain two parallel SSH stories on the same box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UDP is a mess.&lt;/strong&gt; Netstack's UDP support has been partial for years. Concrete symptom: mosh does not work. &lt;code&gt;mosh-server&lt;/code&gt; starts fine, prints its port and key, and the client sits there forever because the datagrams never arrive. If you SSH over flaky mobile links and lean on mosh to survive roaming, userspace mode is a dead end.&lt;/p&gt;

&lt;p&gt;None of this is a bug. It's a documented tradeoff, and for a container that only needs to &lt;em&gt;call out&lt;/em&gt; to a couple of HTTP endpoints, it's a perfectly reasonable one. The problem is that most guides present it as the answer rather than the fallback it actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The false starts
&lt;/h2&gt;

&lt;p&gt;First instinct, recommended constantly on forums: make the container privileged. Set &lt;code&gt;unprivileged: 0&lt;/code&gt;, restore, done, TUN works. It also hands the container a root that maps to real host root, which throws away the single most valuable property of an unprivileged container. For a machine that's about to sit on a VPN and accept inbound connections from anywhere, that trade is exactly backwards.&lt;/p&gt;

&lt;p&gt;Second instinct: &lt;code&gt;--features nesting=1&lt;/code&gt;. That flag shows up in nearly every Proxmox thread about containers doing unusual things, and it's genuinely required for Docker-in-LXC and systemd cgroup delegation. It does nothing for TUN. Nesting controls whether the container can see and mount its own cgroup hierarchy and its own procfs/sysfs views. Device access is a completely separate mechanism. Set it, restart, observe zero change, spend twenty minutes wondering what you got wrong.&lt;/p&gt;

&lt;p&gt;AppArmor is the third dead end. There's a pile of advice suggesting &lt;code&gt;lxc.apparmor.profile: unconfined&lt;/code&gt; to get device access working. That's a sledgehammer for a problem AppArmor isn't causing. The default &lt;code&gt;lxc-container-default-cgns&lt;/code&gt; profile does not block &lt;code&gt;/dev/net/tun&lt;/code&gt; usage; it blocks a set of mount, ptrace, and &lt;code&gt;/proc&lt;/code&gt; write operations, none of which sit in the path here. Dropping confinement to fix a device permission issue is the kind of change that looks like it worked and quietly widens the blast radius. I've seen this pattern bite people in other contexts too, which is the same theme as &lt;a href="https://guatulabs.dev/posts/unprivileged-lxc-docker-the-runc-sysctl-permission-trap/" rel="noopener noreferrer"&gt;the runc sysctl trap&lt;/a&gt;: the fix that "works" is usually the one that removed a boundary you wanted.&lt;/p&gt;

&lt;p&gt;What's actually stopping you is much narrower than any of that.&lt;/p&gt;

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

&lt;p&gt;Three conditions have to hold: the host has the &lt;code&gt;tun&lt;/code&gt; module loaded, the container's device cgroup allows that specific char device, and the device node is bind-mounted into the container's filesystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Load &lt;code&gt;tun&lt;/code&gt; on the host and persist it
&lt;/h3&gt;

&lt;p&gt;On the Proxmox host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsmod | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; tun &lt;span class="o"&gt;||&lt;/span&gt; modprobe tun
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/net/tun
&lt;span class="c"&gt;# crw-rw-rw- 1 root root 10, 200 Aug 14 09:12 /dev/net/tun&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist it so a host reboot doesn't quietly undo everything:&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;tun &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/modules-load.d/tun.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do this &lt;strong&gt;before&lt;/strong&gt; the next step. The bind mount below has a source path on the host. If &lt;code&gt;/dev/net/tun&lt;/code&gt; doesn't exist when the container starts, the mount fails and the container refuses to start, which is a confusing way to learn that a kernel module wasn't loaded.&lt;/p&gt;

&lt;p&gt;Repeat this on every node in the cluster. A container that migrates to a host without &lt;code&gt;tun&lt;/code&gt; loaded will fail to start there, and you'll be debugging a mount error at the worst possible moment.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Add two lines to the container config
&lt;/h3&gt;

&lt;p&gt;Edit &lt;code&gt;/etc/pve/lxc/&amp;lt;CTID&amp;gt;.conf&lt;/code&gt; on the host that currently owns the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none &lt;span class="nb"&gt;bind&lt;/span&gt;,create&lt;span class="o"&gt;=&lt;/span&gt;file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No &lt;code&gt;unprivileged: 0&lt;/code&gt;, no AppArmor changes, no nesting. Two details matter in the second line. The target path &lt;code&gt;dev/net/tun&lt;/code&gt; has &lt;strong&gt;no leading slash&lt;/strong&gt;, because LXC resolves mount targets relative to the container rootfs. And &lt;code&gt;create=file&lt;/code&gt; tells LXC to create the target inode if it doesn't already exist, which it won't on a fresh container.&lt;/p&gt;

&lt;p&gt;The first line uses cgroup v2 syntax. Anything running Proxmox 8.x is on the unified hierarchy, so &lt;code&gt;lxc.cgroup2.devices.allow&lt;/code&gt; is what you want. Older setups on cgroup v1 used &lt;code&gt;lxc.cgroup.devices.allow&lt;/code&gt; with the same argument format; if you're copying a snippet from a 2019 forum post, check which one it uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Stop and start the container from the host
&lt;/h3&gt;

&lt;p&gt;This part catches people. Rebooting from inside the container does not re-read the config file, because the config is consumed by LXC on container creation, not by the init system inside it. You need a full stop/start cycle driven from the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pct stop &amp;lt;CTID&amp;gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pct start &amp;lt;CTID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until you do that, &lt;code&gt;/dev/net/tun&lt;/code&gt; simply won't exist inside the container, and you'll assume the config was wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Drop the userspace flag and bring Tailscale up
&lt;/h3&gt;

&lt;p&gt;If tailscaled was previously running in userspace mode, that flag lives in &lt;code&gt;/etc/default/tailscaled&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="c"&gt;# /etc/default/tailscaled - before&lt;/span&gt;
&lt;span class="nv"&gt;FLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--tun=userspace-networking"&lt;/span&gt;

&lt;span class="c"&gt;# after&lt;/span&gt;
&lt;span class="nv"&gt;FLAGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart and authenticate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart tailscaled
tailscale up &lt;span class="nt"&gt;--hostname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app-container-01 &lt;span class="nt"&gt;--accept-dns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I set &lt;code&gt;--accept-dns=false&lt;/code&gt; on servers by habit. MagicDNS rewriting &lt;code&gt;/etc/resolv.conf&lt;/code&gt; on a box that already has opinions about DNS is a fight you don't need, especially if you're running your own resolver like &lt;a href="https://guatulabs.dev/posts/adguard-home-network-wide-dns-filtering-with-failover/" rel="noopener noreferrer"&gt;AdGuard Home&lt;/a&gt;. Turn it on deliberately if you want it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it actually worked
&lt;/h2&gt;

&lt;p&gt;Four checks, in order. Each one fails differently, which is what makes them useful.&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. The device exists and is world-writable&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/net/tun
&lt;span class="c"&gt;# crw-rw-rw- 1 nobody nogroup 10, 200 Aug 14 09:12 /dev/net/tun&lt;/span&gt;

&lt;span class="c"&gt;# 2. A real kernel interface, not a netstack fiction&lt;/span&gt;
ip addr show tailscale0
&lt;span class="c"&gt;# 3: tailscale0: &amp;lt;POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP&amp;gt; mtu 1280 ...&lt;/span&gt;
&lt;span class="c"&gt;#     inet 100.64.0.31/32 scope global tailscale0&lt;/span&gt;

&lt;span class="c"&gt;# 3. Routes for the tailnet are installed in the container's table&lt;/span&gt;
ip route | &lt;span class="nb"&gt;grep &lt;/span&gt;100.64
&lt;span class="c"&gt;# 100.64.0.0/10 dev tailscale0 scope link&lt;/span&gt;

&lt;span class="c"&gt;# 4. Peer connectivity is direct, not relayed&lt;/span&gt;
tailscale ping 100.64.0.20
&lt;span class="c"&gt;# pong from db-node (100.64.0.20) via 192.0.2.14:41641 in 1ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fourth line is the one worth reading carefully. &lt;code&gt;via &amp;lt;ip&amp;gt;:&amp;lt;port&amp;gt;&lt;/code&gt; means a direct WireGuard tunnel between the two hosts. If it says &lt;code&gt;via DERP(sfo)&lt;/code&gt; instead, you're relaying through Tailscale's infrastructure, which still works but adds latency and depends on someone else's servers. &lt;code&gt;tailscale netcheck&lt;/code&gt; will tell you whether UDP is reachable and whether you're behind a NAT that's blocking direct paths.&lt;/p&gt;

&lt;p&gt;Then test the thing you came for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh user@100.64.0.31       &lt;span class="c"&gt;# OpenSSH, your config, your keys&lt;/span&gt;
mosh user@100.64.0.31      &lt;span class="c"&gt;# UDP works now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it works
&lt;/h2&gt;

&lt;p&gt;Three separate mechanisms have to cooperate, and the reason so much bad advice exists is that people conflate them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device cgroup allowlist.&lt;/strong&gt; An unprivileged LXC container starts with a deny-by-default device policy and a short allowlist covering the basics: &lt;code&gt;/dev/null&lt;/code&gt;, &lt;code&gt;/dev/zero&lt;/code&gt;, &lt;code&gt;/dev/full&lt;/code&gt;, &lt;code&gt;/dev/random&lt;/code&gt;, &lt;code&gt;/dev/urandom&lt;/code&gt;, &lt;code&gt;/dev/tty&lt;/code&gt;, and the pty devices. The TUN driver registers as char device major 10, minor 200. It's not on that list, so any &lt;code&gt;open()&lt;/code&gt; on it returns &lt;code&gt;EPERM&lt;/code&gt; regardless of file permissions. Adding &lt;code&gt;c 10:200 rwm&lt;/code&gt; grants read, write, and mknod for exactly one device node and nothing else. That's the surgical part: you're not opening a category, you're naming a device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bind mount.&lt;/strong&gt; Passing the cgroup check gives permission to use the device, but the container's &lt;code&gt;/dev&lt;/code&gt; is a fresh tmpfs populated by LXC. Creating the node yourself with &lt;code&gt;mknod&lt;/code&gt; inside the container fails, because unprivileged containers don't hold &lt;code&gt;CAP_MKNOD&lt;/code&gt; in a namespace where it means anything for device creation. Bind-mounting the host's existing node sidesteps that entirely: the inode already exists, LXC just makes it visible at the right path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Namespaced capability.&lt;/strong&gt; Opening the device is only half the operation. Actually creating an interface requires the &lt;code&gt;TUNSETIFF&lt;/code&gt; ioctl, which needs &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt;. Here's the part that makes the whole approach safe: &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; is evaluated against the user namespace that owns the network namespace. The container's root has full &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; over its own netns, and zero authority over the host's. So tailscaled can create &lt;code&gt;tailscale0&lt;/code&gt;, set addresses, and install routes inside the container while remaining completely unable to touch host networking. No host privilege is granted at any point.&lt;/p&gt;

&lt;h3&gt;
  
  
  About that &lt;code&gt;nobody:nogroup&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Ownership showing as &lt;code&gt;nobody:nogroup&lt;/code&gt; inside the container is expected, not a symptom. The host node is owned by uid 0. An unprivileged container maps its uid 0 to something like host uid 100000, and host uid 0 falls outside the mapped range, so the kernel reports it as the overflow uid (65534) which resolves to &lt;code&gt;nobody&lt;/code&gt;. The mode is &lt;code&gt;0666&lt;/code&gt;, so every process in the container can open it anyway. People see &lt;code&gt;nobody:nogroup&lt;/code&gt;, assume permissions are broken, and go chase a &lt;code&gt;chown&lt;/code&gt; that will never work. Nothing to fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Direct paths versus the subnet router model
&lt;/h3&gt;

&lt;p&gt;With a kernel interface, the container installs a route for &lt;code&gt;100.64.0.0/10&lt;/code&gt; and encrypts packets itself. Traffic to a peer leaves as UDP on tailscaled's port (41641 by default) over the container's normal LAN interface, straight to the peer's endpoint. No hairpinning through a subnet router, no dependency on one container staying healthy, no single node whose reboot takes out remote access to a dozen services.&lt;/p&gt;

&lt;p&gt;The ACL story improves too. A subnet router advertises a CIDR, so your policy can only reason about IP ranges. Per-node membership lets you tag containers and write rules against identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"acls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"accept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tag:admin"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dst"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tag:lxc-app:22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tag:lxc-app:3000"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tag the container at join time with &lt;code&gt;tailscale up --advertise-tags=tag:lxc-app&lt;/code&gt;, using an auth key whose owner is permitted to assign that tag. Now access is a property of the machine, not a property of whatever subnet it happens to sit in. That distinction matters a lot once more than one person needs access, and it's the same principle behind &lt;a href="https://guatulabs.dev/posts/agent-credential-management-two-tier-service-accounts/" rel="noopener noreferrer"&gt;two-tier service accounts for agents&lt;/a&gt;: scope by identity, not by network position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you restart anything
&lt;/h2&gt;

&lt;p&gt;Step 3 requires stopping the container, which means killing every process inside it. Worth five minutes of preparation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Save your terminal state.&lt;/strong&gt; If you live in tmux inside that container, &lt;code&gt;prefix + Ctrl-s&lt;/code&gt; with tmux-resurrect saves the session layout and running programs. Losing a session with twelve panes of half-finished debugging is a self-inflicted wound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm a recent backup.&lt;/strong&gt; &lt;code&gt;pct listsnapshot &amp;lt;CTID&amp;gt;&lt;/code&gt; or a check in Proxmox Backup Server. The config change is easy to revert, but "the container won't start now" is much less stressful with a restore point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know how you'll get back in.&lt;/strong&gt; &lt;code&gt;pct enter &amp;lt;CTID&amp;gt;&lt;/code&gt; from the host works regardless of container networking. If your only access path is SSH over the very network you're changing, fix that first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record the change.&lt;/strong&gt; Two lines in a config file are trivially forgettable six months later when a fresh container mysteriously lacks TUN. Put them in whatever provisioning script or template you use, alongside the rest of your &lt;a href="https://guatulabs.dev/posts/building-production-homelab/" rel="noopener noreferrer"&gt;cluster build notes&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Device access is not privilege.&lt;/strong&gt; The instinct to reach for &lt;code&gt;unprivileged: 0&lt;/code&gt; comes from treating container security as a binary. It isn't. The device cgroup, capability sets, mount entries, and idmaps are independent knobs, and almost every "just make it privileged" answer online is someone who found one knob and gave up on the rest. Name the exact device you need and grant that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flags are not interchangeable.&lt;/strong&gt; &lt;code&gt;nesting=1&lt;/code&gt; fixes cgroup visibility. AppArmor profiles gate mounts and ptrace. Neither touches &lt;code&gt;/dev&lt;/code&gt;. Reading the actual mechanism took less time than trying flags at random, and I'd skip straight to the mechanism next time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load the module first, always.&lt;/strong&gt; Container start failing on a missing bind-mount source is a confusing error to debug backwards, and the &lt;code&gt;/etc/modules-load.d/tun.conf&lt;/code&gt; file has to exist on every node the container can migrate to. Cluster-wide, not per-node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick one SSH story.&lt;/strong&gt; Running both OpenSSH and Tailscale SSH on the same node means two authorization models, two audit trails, and two places to revoke access. With a kernel TUN device you get to use plain OpenSSH over the tailnet, so I turn Tailscale SSH off on these containers rather than leaving both paths live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The surprise was how boring the fix is.&lt;/strong&gt; Two config lines, one module, one restart. All the complexity lives in understanding why the defaults deny it, which is generally where the interesting part of infrastructure work hides. If you're designing this kind of access model for a team rather than a homelab, that's a conversation I have often through &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;GuatuLabs&lt;/a&gt;, and the boundary questions get more interesting once compliance and multiple operators enter the picture.&lt;/p&gt;

&lt;p&gt;One caveat worth stating plainly: this gives the container a kernel TUN device, not the right to advertise subnet routes. If you also want it acting as a subnet router, you need IP forwarding enabled inside the container and &lt;code&gt;--advertise-routes&lt;/code&gt; on the Tailscale side, which is a different set of tradeoffs. For the common case (a container that should be its own node, reachable directly, with working UDP and your real &lt;code&gt;sshd&lt;/code&gt;), the two lines above are the entire job.&lt;/p&gt;

</description>
      <category>tailscale</category>
      <category>proxmox</category>
      <category>lxc</category>
      <category>networking</category>
    </item>
    <item>
      <title>Systemd-tmpfiles vs Plex: When /tmp Aging Breaks EAC3 Transcoding</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 22 Aug 2026 04:15:47 +0000</pubDate>
      <link>https://dev.to/futhgar/systemd-tmpfiles-vs-plex-when-tmp-aging-breaks-eac3-transcoding-507</link>
      <guid>https://dev.to/futhgar/systemd-tmpfiles-vs-plex-when-tmp-aging-breaks-eac3-transcoding-507</guid>
      <description>&lt;p&gt;A Plex server with 90 days of uptime, a green service status, and perfectly working H.264 playback can still fail every single EAC3 transcode with a generic "Conversion failed" error. Nothing about Plex changed. No update, no config edit, no disk full. A systemd timer quietly deleted the transcoder's scratch directory out from under it, and Plex has no idea.&lt;/p&gt;

&lt;p&gt;This one is worth understanding if you run Plex on any systemd-based Linux, whether that's bare metal, a VM, or an unprivileged LXC on Proxmox. It's especially relevant right now because Debian 13 enabled automatic /tmp cleaning by default, so setups that ran fine for years on Debian 11 or 12 inherit this failure mode on upgrade without touching a single Plex setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;Plex handles most audio transcoding with its bundled ffmpeg fork. But Dolby Digital Plus (EAC3) is licensed differently, so Plex ships a separate binary called EasyAudioEncoder, usually abbreviated EAE. It doesn't work like a normal encoder that reads stdin and writes stdout. It's a watch-folder daemon: Plex drops audio into a directory, EAE picks it up, converts it, and writes the result back. That watch folder lives under a per-instance temp directory that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/tmp/pms-4f3a2b1c-9d8e-7f6a-5b4c-3d2e1f0a9b8c/EasyAudioEncoder/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important detail: Plex creates that directory tree exactly once, when the &lt;code&gt;plexmediaserver&lt;/code&gt; service starts. There's no health check on it, no recreation logic, no periodic touch. As long as the service keeps running, Plex assumes the directory it made on day one is still there.&lt;/p&gt;

&lt;p&gt;Meanwhile, systemd-tmpfiles has its own opinion about /tmp. On most modern distros, &lt;code&gt;/usr/lib/tmpfiles.d/tmp.conf&lt;/code&gt; contains a rule like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clear /tmp entries older than 10 days, /var/tmp after 30&lt;/span&gt;
&lt;span class="err"&gt;q&lt;/span&gt; &lt;span class="err"&gt;/tmp&lt;/span&gt; &lt;span class="err"&gt;1777&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;10d&lt;/span&gt;
&lt;span class="err"&gt;q&lt;/span&gt; &lt;span class="err"&gt;/var/tmp&lt;/span&gt; &lt;span class="err"&gt;1777&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;root&lt;/span&gt; &lt;span class="err"&gt;30d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;systemd-tmpfiles-clean.timer&lt;/code&gt; fires 15 minutes after boot and then once a day, removing anything under /tmp whose access, modification, and change times are all older than the age threshold. If you're a heavy Plex user who transcodes EAC3 daily, the timestamps stay fresh and you never notice. If your household mostly direct-plays and only occasionally hits EAC3 content, the watch folder sits idle, crosses the 10-day line, and gets reaped.&lt;/p&gt;

&lt;p&gt;From that point on, the server is a zombie in one specific dimension. Everything that doesn't need EAE works. Anything that does fails at session start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in the logs
&lt;/h2&gt;

&lt;p&gt;The client-side error is useless: "Conversion failed. The transcoder failed to start up." The actual story is in &lt;code&gt;Plex Media Server.log&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;ERROR - [Req#a4f/Transcode] EAE watchfolder is not writable:
  /tmp/pms-4f3a2b1c-.../EasyAudioEncoder/Convert to WAV (to 8ch or less) (~4f3a)/
ERROR - [Req#a4f/Transcode] Transcode runner appears to have died
DEBUG - Job exit code 187
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 187 from the transcoder job plus "EAE watchfolder is not writable" is the signature. If you go look, the path either doesn't exist at all or the &lt;code&gt;pms-*&lt;/code&gt; parent is gone entirely. You can confirm the cleanup timer is the culprit by checking when it last ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers systemd-tmpfiles-clean.timer
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ld&lt;/span&gt; /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"gone"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The immediate fix is boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart plexmediaserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarting recreates the whole &lt;code&gt;/tmp/pms-*&lt;/code&gt; tree and EAC3 transcodes work again, instantly. Which is exactly why this problem generates so many confused forum threads that end with "restarted it and it works now, no idea why." The restart fixes the symptom and destroys the evidence, and ten days of idle EAE later it's back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permanent fix: a tmpfiles.d exclusion
&lt;/h2&gt;

&lt;p&gt;You don't need to disable /tmp cleaning, and you shouldn't. The &lt;code&gt;tmpfiles.d&lt;/code&gt; format has an exclusion type built for exactly this. Drop one file on the host running Plex:&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;# /etc/tmpfiles.d/plex-eae.conf&lt;/span&gt;
&lt;span class="c"&gt;# Exempt Plex's per-instance temp tree from age-based cleanup.&lt;/span&gt;
&lt;span class="c"&gt;# Lowercase 'x' ignores the path AND everything below it.&lt;/span&gt;
x /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify systemd actually picked it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-tmpfiles &lt;span class="nt"&gt;--cat-config&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; plex-eae
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The character matters here, and it's an easy place to get burned. &lt;code&gt;tmpfiles.d&lt;/code&gt; defines two exclusion types that look almost identical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;x /tmp/pms-*&lt;/code&gt; excludes the matching paths and their entire contents from cleaning. This is what you want.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X /tmp/pms-*&lt;/code&gt; excludes only the directory itself. Its contents still age out, which puts you right back in the same failure with a slightly different shape: the &lt;code&gt;pms-*&lt;/code&gt; directory survives but the &lt;code&gt;EasyAudioEncoder&lt;/code&gt; subtree inside it gets reaped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Globs are legal in exclusion rules and evaluated at cleaning time, so the rule keeps working even though the UUID in the directory name changes on every service restart.&lt;/p&gt;

&lt;p&gt;To prove the fix without waiting ten days, force a cleanup pass with an aggressive age and watch the directory survive:&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;# Dry-run style check: run the cleaner with a 1-second age threshold&lt;/span&gt;
&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"30 days ago"&lt;/span&gt; /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt;/EasyAudioEncoder 2&amp;gt;/dev/null
systemd-tmpfiles &lt;span class="nt"&gt;--clean&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ld&lt;/span&gt; /tmp/pms-&lt;span class="k"&gt;*&lt;/span&gt;/EasyAudioEncoder &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"exclusion works"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the directory is still there after &lt;code&gt;--clean&lt;/code&gt; runs against a backdated mtime, the rule is live. This kind of "verify the fix actually engages" step is the same discipline I argued for in the &lt;a href="https://guatulabs.dev/posts/handling-longhorn-read-only-mounts-detection-recovery-and-preventing-silent-failures/" rel="noopener noreferrer"&gt;Longhorn read-only mounts post&lt;/a&gt;: a service that reports healthy while an underlying filesystem assumption has silently broken is the worst class of failure, because nothing pages you until a user complains.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternative fix: move EAE out of /tmp entirely
&lt;/h2&gt;

&lt;p&gt;If you'd rather not maintain an exclusion rule, you can relocate Plex's temp tree to a path systemd-tmpfiles doesn't manage. EAE derives its working directory from the process environment, not from the "Transcoder temporary directory" setting in the Plex UI. That UI setting moves the video transcode segments, but the EAE watch folders follow &lt;code&gt;TMPDIR&lt;/code&gt;. So changing the setting in the web UI does not fix this problem, which is a trap plenty of people fall into before finding the real mechanism.&lt;/p&gt;

&lt;p&gt;A systemd drop-in handles it cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl edit plexmediaserver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;TMPDIR=/var/lib/plexmediaserver/tmp&lt;/span&gt;
&lt;span class="py"&gt;ExecStartPre&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/bin/mkdir -p /var/lib/plexmediaserver/tmp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a &lt;code&gt;daemon-reload&lt;/code&gt; and restart, the &lt;code&gt;pms-*&lt;/code&gt; tree lands under a path with no age rule attached. The tradeoff is that nothing cleans it up anymore, so orphaned trees from unclean shutdowns accumulate until you notice. The &lt;code&gt;x&lt;/code&gt; exclusion in /tmp doesn't have that problem, because the directory still gets wiped on reboot like everything else in /tmp. That's the main reason I'd pick the exclusion rule over the relocation: it changes the minimum necessary behavior and leaves the rest of the /tmp lifecycle intact.&lt;/p&gt;

&lt;p&gt;There's a third option worth knowing about even if you don't use it: &lt;code&gt;PrivateTmp=true&lt;/code&gt; in the unit. Systemd's private tmp directories get mounted under &lt;code&gt;/tmp/systemd-private-*&lt;/code&gt;, and stock &lt;code&gt;tmp.conf&lt;/code&gt; ships exclusion rules for those paths, so they're immune to aging by design. Plex's packaged unit doesn't enable it, and flipping it on changes how the service sees paths shared with other processes, so I'd treat it as trivia rather than the recommended fix here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and adjacent traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Debian 13 flipped the default.&lt;/strong&gt; Debian historically did not age out /tmp contents on a running system, which is why this bite pattern was mostly a Fedora/Arch/RHEL story for years. Trixie moved /tmp to tmpfs and enabled the 10-day /tmp and 30-day /var/tmp cleanup rules by default. If your Plex LXC or VM got upgraded to Debian 13, you inherited this behavior with zero warning. The exact same Plex install that ran for two years without an EAE incident now has a ten-day fuse that resets only on service restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LXC makes the uptime trap worse.&lt;/strong&gt; Containers restart less often than you think. A Proxmox host reboot cycles every VM, but LXCs with long-running services rack up months of uptime effortlessly, and every one of those days is a day the tmpfiles timer inside the container keeps ticking. Long uptime feels like a stability trophy. For any service that stages state in /tmp at startup and never checks on it again, uptime is actually the risk factor. If you're running Plex inside an unprivileged LXC (a reasonable choice, and the general pattern is covered in &lt;a href="https://guatulabs.dev/posts/building-production-homelab/" rel="noopener noreferrer"&gt;Building a Production Homelab&lt;/a&gt;), put the &lt;code&gt;tmpfiles.d&lt;/code&gt; exclusion inside the container, since that's where the timer that does the reaping lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker installs behave differently.&lt;/strong&gt; A Plex container image doesn't run systemd inside it, so there's no tmpfiles timer to reap anything, and /tmp lives and dies with the container. This failure mode is specific to package installs on systemd hosts and systemd-based LXCs. If you migrated from Docker to a native package and suddenly hit this after a couple of weeks, that's why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't fix it by disabling the timer.&lt;/strong&gt; Masking &lt;code&gt;systemd-tmpfiles-clean.timer&lt;/code&gt; works, but now nothing ages out /var/tmp either, and every other well-behaved consumer of the aging policy loses it too. Scoped exclusions exist precisely so you don't have to make that trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plex isn't the only service with this shape.&lt;/strong&gt; Anything that creates sockets, PID files, or working directories under /tmp at startup and holds a path reference for the life of the process is exposed. PostgreSQL's Unix socket default, some JVM apps with &lt;code&gt;java.io.tmpdir&lt;/code&gt; scratch state, and various daemons with startup-created lock directories all have variants of this. Distros ship tmpfiles exclusions for the common ones (X11 sockets, systemd's own private dirs). Third-party packages are on their own, and Plex's package doesn't ship one. Auditing long-running services for this dependency class is the kind of infrastructure-drift review I end up doing in &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;consulting engagements&lt;/a&gt; more often than any glamorous architecture work, because drift between OS policy and application assumptions is invisible until it isn't.&lt;/p&gt;

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

&lt;p&gt;The application didn't break. The OS didn't malfunction. Two correct behaviors, Plex creating its EAE workspace once at startup and systemd-tmpfiles aging out idle /tmp entries, combined into a failure with a built-in delay timer. That's what makes it nasty: the cause and the symptom are separated by ten days and belong to two different layers of the stack.&lt;/p&gt;

&lt;p&gt;The fix is a one-line file, &lt;code&gt;x /tmp/pms-*&lt;/code&gt; in &lt;code&gt;/etc/tmpfiles.d/plex-eae.conf&lt;/code&gt;, verified with &lt;code&gt;systemd-tmpfiles --cat-config&lt;/code&gt; and a forced &lt;code&gt;--clean&lt;/code&gt; pass. Reach for this pattern any time a long-running service treats /tmp as if it were persistent, and be suspicious of any "it works after a restart" fix where nobody can explain what the restart actually repaired. On a systemd host, a startup-created /tmp directory plus long uptime is a scheduled outage; the only question is the date.&lt;/p&gt;

</description>
      <category>plex</category>
      <category>systemd</category>
      <category>tmpfiles</category>
      <category>transcoding</category>
    </item>
    <item>
      <title>Pushgateway Heartbeat Gotcha: When ndots and NetworkPolicy Silently Eat Your Alerts</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Sat, 22 Aug 2026 02:15:49 +0000</pubDate>
      <link>https://dev.to/futhgar/pushgateway-heartbeat-gotcha-when-ndots-and-networkpolicy-silently-eat-your-alerts-3908</link>
      <guid>https://dev.to/futhgar/pushgateway-heartbeat-gotcha-when-ndots-and-networkpolicy-silently-eat-your-alerts-3908</guid>
      <description>&lt;p&gt;A CronJob exits 0, Pushgateway shows a heartbeat metric that hasn't updated in days, and Prometheus fires nothing. Every dashboard is green while the one thing the heartbeat was supposed to guarantee (that you'd know when the job stops working) has quietly stopped being true.&lt;/p&gt;

&lt;p&gt;This is a gotcha about two Kubernetes features that are each fine on their own: the default &lt;code&gt;ndots:5&lt;/code&gt; DNS behavior and namespace-scoped NetworkPolicies. Put them in the same cluster with a wildcard DNS record and a push-based monitoring pattern, and you get a failure mode where the monitoring infrastructure itself is the victim, which means nothing tells you it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;The heartbeat pattern for batch jobs is simple and well established. Prometheus can't scrape a pod that lived for forty seconds, so instead the job pushes a timestamp to Pushgateway when it finishes, Prometheus scrapes Pushgateway on its normal cycle, and an alert rule fires if the timestamp goes stale. A dead man's switch:&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;# Last line of the backup job's script&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"backup_last_success_timestamp &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="s2"&gt;"&lt;/span&gt; | curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://pushgateway.monitoring.svc.cluster.local:9091/metrics/job/nightly-backup"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackupTooOld&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;time() - backup_last_success_timestamp &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;86400&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the backup breaks, the timestamp stops advancing, the expression crosses the threshold, and I get paged. That's the theory. The name in the curl command is even the full service FQDN, so DNS should be unambiguous. It is not.&lt;/p&gt;

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

&lt;p&gt;Three independent failures stack here, and each one masks the next. That stacking is the whole gotcha; any single layer would have been a five-minute fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: ndots:5 turns your FQDN into a wildcard lookup
&lt;/h3&gt;

&lt;p&gt;A pod with the default &lt;code&gt;dnsPolicy: ClusterFirst&lt;/code&gt; gets a resolv.conf that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;-&lt;span class="n"&gt;jobs&lt;/span&gt;.&lt;span class="n"&gt;svc&lt;/span&gt;.&lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;.&lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;lab&lt;/span&gt;.&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;96&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="n"&gt;ndots&lt;/span&gt;:&lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the last search domain. The kubelet appends the node's own search domains after the cluster ones, so if your nodes carry an internal domain like &lt;code&gt;lab.example.com&lt;/code&gt;, every pod inherits it.&lt;/p&gt;

&lt;p&gt;Now count the dots in &lt;code&gt;pushgateway.monitoring.svc.cluster.local&lt;/code&gt;. Four. The &lt;code&gt;ndots:5&lt;/code&gt; option means any name with fewer than five dots is treated as relative, and the resolver walks the search list before trying the name as-is. So your "fully qualified" name generates these queries, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.ai-jobs.svc.cluster.local&lt;/code&gt; → NXDOMAIN&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.svc.cluster.local&lt;/code&gt; → NXDOMAIN&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.cluster.local&lt;/code&gt; → NXDOMAIN&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pushgateway.monitoring.svc.cluster.local.lab.example.com&lt;/code&gt; → &lt;strong&gt;an answer&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 4 is the trap. If your internal DNS has a wildcard record (&lt;code&gt;*.lab.example.com&lt;/code&gt; pointing at your ingress controller's LoadBalancer VIP, a common setup for homelab TLS and per-app hostnames), that query matches the wildcard and returns the ingress VIP. The resolver got an answer, so it stops. The actual absolute name, the one that resolves to the real Pushgateway ClusterIP, never gets queried.&lt;/p&gt;

&lt;p&gt;Your heartbeat is now aimed at Traefik on port 9091. I've written about this exact mechanism corrupting TLS validation in &lt;a href="https://guatulabs.dev/posts/wildcard-dns-ndots-5-the-tls-nightmare-and-how-to-fix-it/" rel="noopener noreferrer"&gt;Wildcard DNS + ndots:5: The TLS Nightmare&lt;/a&gt;; the Pushgateway variant is nastier because the failure has no user-facing symptom at all.&lt;/p&gt;

&lt;p&gt;The wildcard also changes the &lt;em&gt;character&lt;/em&gt; of the failure. Without it, step 4 would return NXDOMAIN, the resolver would fall through to the absolute name, and everything would work (slower, with three wasted queries, but working). The wildcard converts a harmless inefficiency into a wrong answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: NetworkPolicy makes the wrong answer unreachable, quietly
&lt;/h3&gt;

&lt;p&gt;The monitoring namespace runs default-deny ingress, the pattern from &lt;a href="https://guatulabs.dev/posts/network-policies-with-calico-default-deny-and-namespace-isolation/" rel="noopener noreferrer"&gt;Network Policies with Calico: Default Deny and Namespace Isolation&lt;/a&gt;. Pushgateway has an allow rule for Prometheus scrapes and for the namespaces that existed when the policy was written.&lt;/p&gt;

&lt;p&gt;Two things go wrong at this layer. First, the connection isn't even headed to Pushgateway anymore; it's headed to a LoadBalancer VIP that sits outside the pod and service CIDRs. If the workload namespace has an egress policy scoped to in-cluster traffic, that packet gets dropped on the way out. Second, even after you fix DNS, a &lt;em&gt;new&lt;/em&gt; namespace (say you just added an &lt;code&gt;ai-jobs&lt;/code&gt; namespace for LLM batch work) isn't in Pushgateway's ingress allow-list, so its pushes get dropped on arrival instead.&lt;/p&gt;

&lt;p&gt;Either way, the drop is silent by design. NetworkPolicy denies don't send RST packets or ICMP errors with most CNI configurations. The curl just hangs until its TCP timeout, which on a default curl with no &lt;code&gt;--max-time&lt;/code&gt; can be over two minutes. The job runtime gets longer and nobody notices, because who watches the runtime of a job that's succeeding?&lt;/p&gt;

&lt;p&gt;This is the maintenance-burden face of NetworkPolicy that doesn't show up in tutorials: an allow-list written in March is wrong by August, because namespaces get added and every new one silently falls outside the rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: both the script and the alert fail open
&lt;/h3&gt;

&lt;p&gt;Here's where it becomes invisible. The push was the last line of the script, and it looked like this in the failure case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="s2"&gt;"http://..."&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; was there so a flaky Pushgateway wouldn't fail an otherwise-good backup. Reasonable instinct, terrible outcome: the curl times out, the job still exits 0, and Kubernetes reports the CronJob as succeeding.&lt;/p&gt;

&lt;p&gt;The alert rule fails open too, and this part catches almost everyone. &lt;code&gt;time() - backup_last_success_timestamp &amp;gt; 86400&lt;/code&gt; only evaluates when the series exists. Pushgateway restarted at some point (it stores pushed metrics in memory unless you enable persistence), the series vanished, and the expression started returning an empty result. Empty isn't "greater than 86400." Empty is nothing. No data, no alert.&lt;/p&gt;

&lt;p&gt;So: the DNS layer sends the heartbeat to the wrong place, the network layer drops it without an error, the script layer hides the failure from Kubernetes, and the alerting layer can't fire on a metric that doesn't exist. Four layers, zero signals.&lt;/p&gt;

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

&lt;p&gt;Work from the inside out: make the failure loud first, then fix the path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Make the push failure fail the job.&lt;/strong&gt; Drop the &lt;code&gt;|| true&lt;/code&gt;, add &lt;code&gt;-f&lt;/code&gt; so HTTP errors count, and bound the timeout so a network drop fails in seconds instead of minutes:&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;"backup_last_success_timestamp &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="s2"&gt;"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 2 &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @- &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://pushgateway.monitoring.svc.cluster.local.:9091/metrics/job/nightly-backup"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the trailing dot on the hostname. That marks the name as absolute and bypasses the search list entirely, which makes this one command immune to the ndots problem regardless of pod configuration. It's the cheapest fix in this whole post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fix ndots at the pod level.&lt;/strong&gt; The trailing dot fixes one command; &lt;code&gt;dnsConfig&lt;/code&gt; fixes every lookup the pod makes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CronJob&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;dnsConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ndots&lt;/span&gt;
                &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;ndots:2&lt;/code&gt;, any name with two or more dots gets tried as an absolute name first. &lt;code&gt;pushgateway.monitoring.svc.cluster.local&lt;/code&gt; resolves on the first query, and even the short form &lt;code&gt;pushgateway.monitoring&lt;/code&gt; still works because the search list is consulted after the absolute attempt fails. You lose nothing for cluster-internal names and you stop feeding four-dot FQDNs to your wildcard record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Expand the NetworkPolicy with labels, not names.&lt;/strong&gt; Enumerating source namespaces by name is what made the policy rot when a new namespace arrived. Select on a label instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;allow-pushgateway-clients&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;monitoring&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pushgateway&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Ingress&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;namespaceSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;pushgateway-client&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;9091&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then onboarding a new batch namespace is one label away: &lt;code&gt;kubectl label namespace ai-jobs pushgateway-client=true&lt;/code&gt;. The policy stops being a file you have to remember to edit and becomes a contract the namespace opts into. Pair it with a Kyverno rule that requires the label on namespaces containing CronJobs if you want it enforced rather than remembered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Alert on absence, not just staleness.&lt;/strong&gt; The dead man's switch needs to fire when the series is missing, because "missing" is exactly what the failure mode produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackupHeartbeatMissing&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;(time() - backup_last_success_timestamp &amp;gt; 86400)&lt;/span&gt;
    &lt;span class="s"&gt;or&lt;/span&gt;
    &lt;span class="s"&gt;absent(backup_last_success_timestamp)&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;absent()&lt;/code&gt; function returns a value of 1 when the series doesn't exist, and the &lt;code&gt;or&lt;/code&gt; makes the rule cover both stale and gone. One caveat: &lt;code&gt;absent()&lt;/code&gt; collapses labels, so if you push heartbeats for several jobs under one metric name with a &lt;code&gt;job&lt;/code&gt; label, you need one &lt;code&gt;absent()&lt;/code&gt; clause per job (or a recording-rule pattern). Tedious, but the alternative is the exact blind spot this post is about. The reasoning behind alerting on the negative space is the same one I laid out in &lt;a href="https://guatulabs.dev/posts/prometheus-alerting-rules-that-don-t-cry-wolf/" rel="noopener noreferrer"&gt;Prometheus Alerting Rules That Don't Cry Wolf&lt;/a&gt;: an alert that can't fire when the pipeline feeding it dies isn't an alert, it's decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Verify the path from inside the namespace.&lt;/strong&gt; Don't trust the fix until you've watched the resolution happen where the workload runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run &lt;span class="nt"&gt;-n&lt;/span&gt; ai-jobs dbg &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  getent hosts pushgateway.monitoring.svc.cluster.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns your ingress VIP instead of a ClusterIP, the wildcard is still winning. Follow with a &lt;code&gt;curl -v --max-time 5&lt;/code&gt; to port 9091 to confirm the NetworkPolicy actually passes traffic, because DNS being right says nothing about the packet getting through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;You'll hit this if your cluster combines three things that are each individually recommended: a wildcard DNS record for ingress convenience, default-deny NetworkPolicies, and push-based heartbeats for batch workloads. Plenty of production clusters and most serious homelabs check all three boxes. The layers interact in a way none of their docs mention, and the interaction specifically targets the observability path, so the usual answer ("the alert will catch it") doesn't apply. The alert is the thing that's broken.&lt;/p&gt;

&lt;p&gt;The deeper lesson is about failure direction. Every layer in this chain failed &lt;em&gt;open&lt;/em&gt;: the wildcard answered instead of erroring, the policy dropped instead of rejecting, the script swallowed the exit code, the alert evaluated to empty. Whenever you build a monitoring path, walk it backwards and ask what each hop does when its input disappears. Anything that goes quiet instead of loud needs an &lt;code&gt;absent()&lt;/code&gt;, a &lt;code&gt;-f&lt;/code&gt;, or a timeout wrapped around it. This applies well beyond Pushgateway; the same audit caught a gap in how I verify vector database backups, which I covered in &lt;a href="https://guatulabs.dev/posts/backing-up-qdrant-snapshots-correctly-from-emptydir-to-nfs-persistent-backups/" rel="noopener noreferrer"&gt;Backing Up Qdrant Snapshots Correctly&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Cross-layer failures like this one, where DNS, network policy, and alerting each look correct in isolation, are the most expensive class of problem in infrastructure work, and they're a big part of the reliability engineering &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;I consult on&lt;/a&gt;. The fix is rarely clever. It's knowing that the layers talk to each other, and testing the seams instead of the components.&lt;/p&gt;

&lt;p&gt;What I'd tell you to do today, in order: add the trailing dot to your push URLs, add &lt;code&gt;absent()&lt;/code&gt; to every dead man's switch you own, and run &lt;code&gt;getent hosts&lt;/code&gt; for your internal service names from inside a workload namespace. The first two take five minutes. The third will either confirm you're fine or save you from finding out the hard way that your heartbeats have been landing in Traefik's 404 handler.&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>pushgateway</category>
      <category>dns</category>
      <category>networkpolicies</category>
    </item>
    <item>
      <title>We Almost Deployed a Temporal Knowledge Graph. The Eval Said No.</title>
      <dc:creator>Guatu</dc:creator>
      <pubDate>Fri, 14 Aug 2026 06:15:48 +0000</pubDate>
      <link>https://dev.to/futhgar/we-almost-deployed-a-temporal-knowledge-graph-the-eval-said-no-3ld</link>
      <guid>https://dev.to/futhgar/we-almost-deployed-a-temporal-knowledge-graph-the-eval-said-no-3ld</guid>
      <description>&lt;p&gt;The eval that killed the temporal knowledge graph asserted one thing: at time T, the agent should report the state that was true at T. It failed 41% of the time. The graph had the right facts. It just handed the agent the wrong one.&lt;/p&gt;

&lt;p&gt;That number is what saved us from shipping. Every static retrieval metric looked fine. The graph answered "what is the status of Node A" with a confident, well-formed response. Trouble is, "what is the status" is a temporal question wearing a static question's clothes, and nothing in our test suite had noticed the difference until we wrote a test that actually asked about time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I expected
&lt;/h2&gt;

&lt;p&gt;The pitch for a temporal knowledge graph (TKG) is genuinely good. You store facts as quadruples instead of triples: &lt;code&gt;(subject, predicate, object, timestamp)&lt;/code&gt; or, better, &lt;code&gt;(subject, predicate, object, valid_from, valid_to)&lt;/code&gt;. Now your agent memory isn't a flat pile of embeddings, it's a structured record of what was true and when. This is the natural next step past pure vector recall, and it slots neatly into the decay-based thinking I've written about before in &lt;a href="https://dev.to/posts/eviction-without-deletion-running-an-act-r-decay-policy-for-agent-memory-in-production/"&gt;Eviction Without Deletion&lt;/a&gt;. Instead of letting old facts fade by activation weight, you make validity windows explicit.&lt;/p&gt;

&lt;p&gt;My hope was that the graph would fix the exact failure mode that plagues flat vector memory: the agent confidently recalling a stale fact because it's semantically close to the query. With &lt;code&gt;valid_from&lt;/code&gt; and &lt;code&gt;valid_to&lt;/code&gt; on every edge, staleness becomes a filter, not a guess. Ask for the state at time T, filter edges where T falls inside the window, done. On paper it's cleaner than a decay curve because there's no fuzziness. A fact is either valid at T or it isn't.&lt;/p&gt;

&lt;p&gt;Schema-wise, it was simple enough. In a property graph it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A temporal fact: Node A was in maintenance for a fixed window&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;n:&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="s1"&gt;'node-a'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:HAS_STATE&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;status:&lt;/span&gt; &lt;span class="s1"&gt;'maintenance'&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
  &lt;span class="py"&gt;valid_from:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-20T02:00:00Z'&lt;/span&gt;&lt;span class="ss"&gt;),&lt;/span&gt;
  &lt;span class="py"&gt;valid_to:&lt;/span&gt;   &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-07-20T04:30:00Z'&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;:State&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;kind:&lt;/span&gt; &lt;span class="s1"&gt;'maintenance'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiple &lt;code&gt;HAS_STATE&lt;/code&gt; edges per server, each with its own window. Query the graph, get the state for any point in time. This is the "structured shared memory" pattern I described in &lt;a href="https://dev.to/posts/multi-agent-ai-systems-architecture-patterns/"&gt;Multi-Agent AI Systems&lt;/a&gt;, except now the shared memory understands time. The whole thing felt like an upgrade in every dimension. It reads well in a design doc. It demos beautifully. That was part of the problem.&lt;/p&gt;

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

&lt;p&gt;Retrieval is where it fell apart, and the failure is boring in a way that makes it dangerous. Here's roughly the query the agent's tool was generating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The "obvious" query - find the status of a server&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;n:&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="n"&gt;$server&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;r:&lt;/span&gt;&lt;span class="n"&gt;HAS_STATE&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;s.status&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_to&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that carefully. It orders by &lt;code&gt;valid_from&lt;/code&gt; descending and takes the most recent fact. Most of the time that's correct, because the most recently started state is usually the current one. What it never does is filter by the query's reference time. If the agent is reasoning about an incident that happened at 03:00, and a newer "online" state was recorded at 05:00, this query returns "online." The graph knew Node A was in maintenance at 03:00. The retrieval logic threw that knowledge away.&lt;/p&gt;

&lt;p&gt;This is the hallucinated-history problem, and it's insidious because the model isn't hallucinating. The fact is real. The timestamp is real. The agent is just being handed a fact from the wrong window and has no way to know it. Worse, the answer is fluent and specific, so every static evaluation gives it a pass. RAGAS-style faithfulness checks look at whether the answer is grounded in the retrieved context. It was. The retrieved context was simply the wrong slice of time.&lt;/p&gt;

&lt;p&gt;I want to be precise about where the failure lived, because it wasn't the graph. The graph was correct. The schema was correct. The data was correct. The failure was split across two places: a retrieval query that dropped the temporal filter, and an evaluation suite that had no test capable of noticing. If we'd only had the first problem, we'd have caught it in review. Having both meant the system looked healthy right up until the one test that mattered.&lt;/p&gt;

&lt;p&gt;That missing test is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;agent_memory&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;query_state&lt;/span&gt;  &lt;span class="c1"&gt;# our TKG retrieval tool
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_temporal_regression&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Agent must report the state valid AT the reference time,
    not the most recently recorded state.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# Maintenance window: 02:00-04:30. Online recorded at 05:00.
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-20T03:00:00Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# A later 'online' fact exists, but at 03:00 the truth is 'maintenance'
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maintenance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temporal regression: got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;for a timestamp inside the maintenance window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that single assertion across a few dozen historical state transitions and you get the 41% failure rate. Not a subtle degradation. Nearly half of all time-scoped questions returned a state from the wrong window whenever a newer fact existed. Meanwhile the static suite, which only checked "does the agent know the current status," passed everything. Two evals looking at the same system, one green, one red, and only the red one described reality.&lt;/p&gt;

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

&lt;p&gt;Correcting the retrieval was one clause. You filter edges so the reference time falls inside the validity window before you order or limit anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Filter by the reference time FIRST, then pick the winner&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;n:&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;name:&lt;/span&gt; &lt;span class="n"&gt;$server&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;r:&lt;/span&gt;&lt;span class="n"&gt;HAS_STATE&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;$at&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r.valid_to&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="ow"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;r.valid_to&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;$at&lt;/span&gt;&lt;span class="ss"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;s.status&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r.valid_to&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;r.valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;valid_to IS NULL&lt;/code&gt; handles the open-ended "current" state, the fact that has started but not yet ended. Everything else is a closed window, and the &lt;code&gt;WHERE&lt;/code&gt; clause guarantees you only ever consider edges whose window contains T. The &lt;code&gt;ORDER BY ... LIMIT 1&lt;/code&gt; is still there to break ties if two windows overlap, but now it's picking among facts that are all actually valid at T, not among every fact ever recorded.&lt;/p&gt;

&lt;p&gt;One clause. That's the entire retrieval fix. Which tells you the real bug was never in the query, it was in the fact that nobody wrote the eval that would have made the missing clause obvious on day one.&lt;/p&gt;

&lt;p&gt;So the second half of the fix was the more important one: the retrieval tool never gets to reason about time on its own. The agent isn't trusted to remember to pass a reference timestamp, and the LLM isn't trusted to filter windows in its head. Instead, the current time is injected as explicit context at the tool boundary, and the tool refuses to answer a state question without it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query_state requires a reference time. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Temporal facts are meaningless without one.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# ... run the time-filtered Cypher above ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Making the reference time a required argument sounds trivial. It's the difference between a tool that silently returns plausible garbage and one that fails loudly when it's used wrong. A loud failure is a bug report. A plausible answer from the wrong window is an incident three weeks later that nobody can reproduce.&lt;/p&gt;

&lt;p&gt;We also changed how the retrieved fact is handed to the model. Rather than passing "status: maintenance" as a bare string, the prompt gets the window with it: "As of 2026-07-20T03:00:00Z, node-a status is 'maintenance' (valid 02:00-04:30). A later 'online' state exists from 05:00 and is not applicable to this query." Giving the model the window and the reference time in the same breath means that even if the retrieval ever regresses, the model has a fighting chance to notice the mismatch. Defense in depth, applied to a knowledge base.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;I keep coming back to the fact that we did not ship this because of the architecture. We almost shipped it because the architecture was correct and only the evaluation was wrong. That inversion is the whole lesson. A TKG is more capable than flat vector memory, and that extra capability comes with an entirely new class of failure that your existing evals were never designed to see. Adding temporal structure to your memory adds temporal bugs, and static tests are blind to all of them by construction.&lt;/p&gt;

&lt;p&gt;Here's the trap I see constantly. Teams treat GraphRAG as a strictly-better upgrade over vector RAG, port their old eval suite unchanged, watch it stay green, and conclude the new system is at least as good as the old one. Their green suite is measuring the properties the old system could fail on. It has no assertion about sequence, no assertion about validity windows, no assertion that state at T equals the state that was actually true at T. The new failure mode is invisible not because it's rare but because nothing is looking for it. This is the same gap I described in &lt;a href="https://dev.to/posts/cognitive-memory-for-agents-vector-search-vs-activation-based-recall/"&gt;Cognitive Memory for Agents&lt;/a&gt;: the retrieval method changed, so the questions your evals ask have to change too.&lt;/p&gt;

&lt;p&gt;If you're building temporal memory for an agent, write the temporal regression test before you write the graph. Seed a handful of known state transitions where a later fact contradicts an earlier one, then assert that a query scoped to the earlier window returns the earlier fact. That test is a dozen lines. It will fail the moment your retrieval forgets to filter by time, which, based on how naturally that &lt;code&gt;ORDER BY valid_from DESC LIMIT 1&lt;/code&gt; query wrote itself, is going to be your very first implementation.&lt;/p&gt;

&lt;p&gt;A few things I'd carry into the next attempt. Make the reference time a required parameter on every temporal query, so the tool cannot be called ambiguously. Keep the graph correct and put your paranoia in the retrieval and the eval, because that's where the wrong-window bug actually lives. Expose temporal queries to the agent through a narrow, well-typed interface, whether that's a tool boundary or an &lt;a href="https://dev.to/posts/building-mcp-servers-with-fastmcp/"&gt;MCP server&lt;/a&gt;, so the agent can't hand-roll a query that drops the filter. And treat "it passed the static eval" as necessary, never sufficient, the second time itself becomes a dimension of your data. If you're standing up this kind of time-aware memory for a production agent system and want a second set of eyes on the failure modes, that's a chunk of what I do &lt;a href="https://guatulabs.com/services" rel="noopener noreferrer"&gt;consulting&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We didn't deploy the temporal knowledge graph that quarter. We deployed the eval, fixed the one-clause retrieval bug it exposed, and shipped the graph once the red test went green. The graph was never the risky part. The risky part was almost trusting a system that no test had ever asked the right question.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>knowledgegraph</category>
      <category>evaluation</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
