<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Etcd on bismigel</title><link>https://bismigel.dev/tags/etcd/</link><description>Recent content in Etcd on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Wed, 28 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://bismigel.dev/tags/etcd/index.xml" rel="self" type="application/rss+xml"/><item><title>Replacing the etcd node: a 2012 MacBook for a ThinkCentre</title><link>https://bismigel.dev/posts/2026-10-28-replacing-the-etcd-node/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate><guid>https://bismigel.dev/posts/2026-10-28-replacing-the-etcd-node/</guid><description/><content:encoded><![CDATA[<p>The third etcd member of my cluster was a <strong>MacBook Pro 13&quot; from 2012</strong>. It sat closed on a shelf, permanently on charge, doing nothing but keeping quorum. It worked. It also had a swollen battery, a Broadcom network card, and no way to recover it except walking over and opening the lid.</p>
<p>This is the story of replacing it with a used ThinkCentre M900 Tiny, and of the three places that still pointed at the old machine after I thought the job was done.</p>
<h1 id="why-bother">Why bother</h1>
<p>The MacBook was not broken. That is the honest framing: nothing forced this change, and a migration is risk you choose. What made it worth the risk:</p>
<ul>
<li><strong>Hardware age.</strong> 2012 laptop hardware, permanently charging, is a battery waiting to become a fire risk and a disk waiting to fail.</li>
<li><strong>A single noisy dependency.</strong> It held etcd only, so it ran nothing useful and still had to be up for every write to the cluster.</li>
<li><strong>No remote recovery.</strong> Every other node can be recovered over SSH. That one needed hands on it.</li>
</ul>
<p>The replacement is a ThinkCentre M900 Tiny: i5-6500T, 16 GB, a 256 GB SATA SSD, one Intel I219-V NIC. It is boring hardware, which is the point.</p>
<h1 id="the-installer-that-could-not-kexec">The installer that could not kexec</h1>
<p>I booted a Void live image to install NixOS. The standard tool for this is <code>nixos-anywhere</code>, which loads a small NixOS installer into RAM with kexec, so the target disk can be wiped while the running system lives in memory.</p>
<p>It failed:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kexec_file_load failed: Operation not permitted</span></span></code></pre></div>
</div>
<p>The cause was not Secure Boot (it was off) and not lockdown (the state was <code>none</code>). It was <code>kernel.kexec_load_disabled = 1</code>, set by the live image, and it is a <strong>one-way</strong> switch. You cannot turn it back on, and a reboot does not help because the image sets it again.</p>
<p>So the standard path was gone. The workaround: install Nix on the live system and run the phases <code>nixos-anywhere</code> would have run, without the kexec step.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">bash</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># the distro&#39;s nix package was broken (CXXABI mismatch), so: official tarball</span>
</span></span><span class="line"><span class="cl">curl -sSLO https://releases.nixos.org/nix/nix-2.28.3/nix-2.28.3-x86_64-linux.tar.xz
</span></span><span class="line"><span class="cl">tar -xf nix-2.28.3-x86_64-linux.tar.xz
</span></span><span class="line"><span class="cl">./nix-2.28.3-x86_64-linux/install --no-daemon --tarball nix-2.28.3-x86_64-linux.tar.xz
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">nixos-anywhere --phases disko,install --flake .#patola-thinkcentre <span class="se">\
</span></span></span><span class="line"><span class="cl">  --target-host root@192.168.2.11</span></span></code></pre></div>
</div>
<p>Two surprises in there worth keeping:</p>
<ul>
<li>The tarball is <code>xz</code>, and the live image had no <code>xz</code>. Repacking it as <code>gz</code> on my workstation was faster than installing a compressor on a system I was about to destroy.</li>
<li><code>nixos-install</code> was not in <code>PATH</code> either. It lives in <code>nixos-install-tools</code>, so <code>nix build nixpkgs#nixos-install-tools</code> plus a symlink into <code>/usr/local/bin</code> was enough.</li>
</ul>
<h1 id="keep-the-host-key-or-re-encrypt-your-secrets">Keep the host key or re-encrypt your secrets</h1>
<p>In this lab the <strong>host SSH key is the SOPS age identity</strong>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">nix</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nix" data-lang="nix"><span class="line"><span class="cl"><span class="n">sops</span><span class="o">.</span><span class="n">age</span><span class="o">.</span><span class="n">sshKeyPaths</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">&#34;/etc/ssh/ssh_host_ed25519_key&#34;</span> <span class="p">];</span></span></span></code></pre></div>
</div>
<p>A fresh install generates a new host key, which is a new age identity, which means the box cannot decrypt <code>secrets/homelab.yaml</code> - so <code>k3s</code> sits in <code>activating</code> forever, waiting for a token file that will never appear.</p>
<p>The log is honest about it, at least:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Waiting for file &#34;/run/secrets/k3s_cluster_token&#34; to be created</span></span></code></pre></div>
</div>
<p>The first install taught me this the hard way. For the second install (the one with the final partition layout) I saved <code>/etc/ssh/ssh_host_ed25519_key</code> before wiping, restored it into <code>/mnt/etc/ssh</code> after <code>nixos-install</code> and before the reboot. The token decrypted on first boot and the node joined without me touching SOPS again.</p>
<p>If you cannot preserve the key, the alternative is fine too: derive the new age public key with <code>ssh-to-age</code>, add it to <code>.sops.yaml</code>, run <code>sops updatekeys</code>.</p>
<h1 id="the-partition-layout-should-match-the-others">The partition layout should match the others</h1>
<p>My first install used the whole 238 GB SSD as root. Three other nodes in this cluster use the same shape: a 1 G ESP, a <strong>100 G root</strong>, and the rest as a separate data partition. I rebuilt to match, for one reason:</p>
<blockquote>
<p>A partition boundary is the only thing that stops a runaway workload from filling the filesystem that etcd lives on.</p>
</blockquote>
<p>ext4, not btrfs. NixOS generations already give me rollback, which is what snapshots would be bought for, while Longhorn wants ext4 or XFS under its replica directory and etcd fsyncs on this same disk.</p>
<h1 id="adding-a-member-without-losing-quorum">Adding a member without losing quorum</h1>
<p>The cluster ran three etcd members. Adding a fourth so I could remove the MacBook is fine <strong>temporarily</strong> - four members tolerate one failure, same as three, but need three alive - and &ldquo;temporarily&rdquo; is the whole point.</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">1. new node joins as a server        -&gt; 4 members (quorum 3, nothing else may go down)
</span></span><span class="line"><span class="cl">2. stop k3s on the old node          -&gt; 3 of 4 alive, still quorate
</span></span><span class="line"><span class="cl">3. etcdctl member remove &lt;old-id&gt;    -&gt; back to 3 members</span></span></code></pre></div>
</div>
<p>At no point were fewer than three members available. A permanent fourth member would be strictly worse than three: same tolerance, higher quorum.</p>
<p>The first join attempt timed out, and the reason is worth writing down. Every node&rsquo;s firewall allowlists the etcd and kubelet ports <strong>by source address range</strong>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">nix</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nix" data-lang="nix"><span class="line"><span class="cl"><span class="n">iptables</span> <span class="err">-</span><span class="n">I</span> <span class="n">nixos-fw</span> <span class="mi">1</span> <span class="err">-</span><span class="n">p</span> <span class="n">tcp</span> <span class="err">-</span><span class="n">m</span> <span class="n">iprange</span> <span class="err">--</span><span class="n">src-range</span> <span class="mi">192</span><span class="mf">.168.2</span><span class="o">.</span><span class="err">81</span><span class="mi">-192</span><span class="mf">.168.2.83</span> <span class="err">\</span>
</span></span><span class="line"><span class="cl">  <span class="err">-</span><span class="n">m</span> <span class="n">multiport</span> <span class="err">--</span><span class="n">dports</span> <span class="mi">2379</span><span class="err">,</span><span class="mi">2380</span><span class="err">,</span><span class="mi">10250</span> <span class="err">-</span><span class="n">j</span> <span class="n">nixos-fw-accept</span></span></span></code></pre></div>
</div>
<p>The new node was <code>.84</code>, outside that range. I had merged the widened rule but not deployed it, so <code>MemberAdd request timed out</code> for several minutes while every node looked perfectly healthy. The firewall was doing exactly what it said.</p>
<h1 id="the-node-that-refused-to-become-a-worker">The node that refused to become a worker</h1>
<p>The MacBook was not empty. It held <strong>Immich&rsquo;s library and Postgres</strong> on node-local volumes. Removing it from the cluster put those pods into <code>Pending</code>, and that is entirely my fault: I removed a node without checking what ran on it.</p>
<p>The fix was to rejoin it as a <strong>worker</strong> (role = &ldquo;agent&rdquo;, no etcd). That produced the next failure, which is a nice example of how a config error hides behind a rollback:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Deployment to node kamatis-mbp failed, rolled back to previous generation</span></span></code></pre></div>
</div>
<p>The unit had server-only flags still in it - <code>--flannel-backend=none</code>, <code>--disable traefik</code>, <code>--write-kubeconfig-mode</code> - and <code>k3s agent</code> rejects them. The rollback put it back on the <strong>server</strong> config, which cheerfully re-added it to etcd, so I briefly had four members again and had to remove it a second time. Agent mode keeps the kubelet and kube-proxy flags and drops the server ones.</p>
<h1 id="moving-the-volumes-three-pins-and-one-lie">Moving the volumes: three pins and one lie</h1>
<p>Moving Immich off the MacBook looked like &ldquo;copy 1.8 GB and change a path&rdquo;. It was four separate obstacles.</p>
<p><strong>First</strong>, <code>kubectl scale --replicas=0</code> does not stop a GitOps workload. ArgoCD self-heal put the pods back within seconds. Copying a live Postgres data directory is how you get a corrupt database that looks fine until it does not, so the scale-down had to be a commit, and the scale-up another one.</p>
<p><strong>Second</strong>, <code>spec.nodeAffinity</code> on a PersistentVolume is <strong>immutable</strong>. Kubernetes says so plainly:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">The PersistentVolume &#34;immich-library&#34; is invalid: nodeAffinity: Invalid value:
</span></span><span class="line"><span class="cl">field is immutable</span></span></code></pre></div>
</div>
<p>So the PV has to be deleted and recreated. With <code>persistentVolumeReclaimPolicy: Retain</code> the data on disk is untouched; only the object goes.</p>
<p><strong>Third</strong>, the node name was pinned in three different places:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">PersistentVolume.spec.nodeAffinity       -&gt; values: [kamatis-mbp]
</span></span><span class="line"><span class="cl">Pod.spec.nodeSelector  (redis, server)   -&gt; kubernetes.io/hostname: kamatis-mbp
</span></span><span class="line"><span class="cl">StatefulSet.spec.template.nodeSelector   -&gt; kubernetes.io/hostname: kamatis-mbp</span></span></code></pre></div>
</div>
<p>I fixed two of them and wondered why pods still sat at =0/4 nodes are available: 1 node(s) didn&rsquo;t match PersistentVolume&rsquo;s node affinity, 3 node(s) didn&rsquo;t match Pod&rsquo;s node affinity/selector=. That error message is actually excellent - it tells you both sides disagree.</p>
<p><strong>Fourth</strong>, a StatefulSet does not recreate its pod when only the template changes. The corrected selector did nothing until I deleted <code>immich-postgres-0</code> and let the controller rebuild it.</p>
<p>After all that: 490 assets in Postgres, 1562 files in the library, and the same files byte-for-byte on the new node, verified before the switch with <code>tar --compare</code> over an SSH stream.</p>
<h1 id="what-the-older-monitors-got-wrong">What the older monitors got wrong</h1>
<p>Two checks hard-coded the old topology and started failing on a healthy cluster:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">smoke test:        [ &#34;$ready&#34; = &#34;3&#34; ]              -&gt; now: -ge 3
</span></span><span class="line"><span class="cl">smoke test:        grep &#34;Cluster health:.*3/3&#34;     -&gt; now: parses N/N
</span></span><span class="line"><span class="cl">docs-verify:       expected=&#34;&lt;3 nodes&gt;&#34;            -&gt; now: 4 nodes
</span></span><span class="line"><span class="cl">docs-verify:       agents -eq 3                    -&gt; now: agents == node count</span></span></code></pre></div>
</div>
<p>Both were fine when written. Both became lies the moment the cluster grew, and the failure mode was worse than no check at all: a permanent <code>SmokeTestFailed</code> on a perfectly healthy lab teaches you to ignore the alert.</p>
<h1 id="where-it-ended-up">Where it ended up</h1>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">text</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">nodes   4 ready: .81 media, .82 platform, .84 ThinkCentre (etcd), .83 MacBook (worker)
</span></span><span class="line"><span class="cl">etcd    3 members, leader on .81, ~10 ms commit
</span></span><span class="line"><span class="cl">apps    31/31 synced and healthy</span></span></code></pre></div>
</div>
<p>The MacBook still exists. It runs nothing but the DaemonSets, and it will stay until I decide whether it becomes the staging host or gets switched off for good.</p>
<h1 id="what-i-actually-learned">What I actually learned</h1>
<ul>
<li><strong>Check what a node carries before removing it.</strong> Node-local volumes are invisible until the pods go Pending. That one was avoidable and cost an afternoon.</li>
<li><strong>A rollback can undo your intent.</strong> The failed agent deploy restored the server config, which rejoined etcd - the opposite of what I asked for. Read what the tool did, not what it was supposed to do.</li>
<li><strong>Hard-coded counts in monitors are technical debt with a firing alert.</strong></li>
<li><strong>Preserving the host key is cheaper than re-encrypting secrets.</strong> One <code>scp</code> before the install saved a SOPS round trip.</li>
<li><strong>Live images are not all the same.</strong> <code>kexec_load_disabled</code> is a legitimate security default that removes the standard install path, and nothing warns you until you try.</li>
</ul>
]]></content:encoded></item></channel></rss>