<?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>Nebula on paradigmatic.systems</title>
    <link>https://paradigmatic.systems/tags/nebula/</link>
    <description>Recent content in Nebula on paradigmatic.systems</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <lastBuildDate>Sat, 03 Oct 2026 19:00:00 -0400</lastBuildDate><atom:link href="https://paradigmatic.systems/tags/nebula/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Rhizomatic Systems; Or, the Maximum Rizz YOLO Homelab</title>
      <link>https://paradigmatic.systems/posts/rhizomatic-systems/</link>
      <pubDate>Sat, 03 Oct 2026 19:00:00 -0400</pubDate>
      
      <guid>https://paradigmatic.systems/posts/rhizomatic-systems/</guid>
      <description>A NixOS monorepo pattern where everything can see everything, and updates arrive as CI commits.</description>
      <content:encoded><![CDATA[<h2 id="the-tree-and-the-rhizome">The Tree and the Rhizome</h2>
<p>Systems models are typically <em>arborescent</em>, or tree-like. There is a root source of truth, and everything else extends from there. Kubernetes has its API server. Ansible has whichever laptop you ran the playbook from. Terraform has a state file with a lock. Even my own <a href="/posts/setting-up-deploy-rs">deploy-rs setup</a> had a root: one machine with the keys, pushing closures outward to nodes that could only receive. That&rsquo;s a fine shape when an ops team <em>is</em> the root. For one person with a few computers, it means one of them is special, and the whole system is only as available as that one.</p>
<figure class="align-center ">
    <img loading="lazy" src="/rhizome-vs-tree.jpg#center"
         alt="A tree with a single trunk and branching roots beside a rhizome, a tangle of shoots and roots with no center"/> <figcaption>
            <p>Tree and rhizome.
                    <a href="https://commons.wikimedia.org/wiki/File:Rhizome_vs_tree.jpg">Drawing by Magda Wojtyra and Marc Ngui, CC BY-SA 4.0, via Wikimedia Commons</a></p>
        </figcaption>
</figure>

<p>A <em>rhizome</em>, like ginger or crabgrass, is a network of connected points. In <a href="https://en.wikipedia.org/wiki/A_Thousand_Plateaus"><em>A Thousand Plateaus</em></a>, Deleuze and Guattari lay out some of the principles of <em>rhizomatic thought</em>. A rhizome connects different types of things together, with no central pivot. A rhizome may be broken at any point, and it starts up again along its old lines or along new ones. Finally, they insist on the <em>map</em> over the <em>tracing</em>: a tracing copies a structure that was decided in advance, while a map is drawn from whatever actually connected and stays open to redrawing.</p>
<p>But this isn&rsquo;t a philosophy post. It&rsquo;s about how the endgame of my fleet configuration ended up taking a rhizomatic shape due to a handful of decisions.</p>
<h2 id="the-fleet-config-monorepo">The Fleet Config Monorepo</h2>
<p>If you install NixOS on a single computer, you end up with a <code>/etc/nixos/configuration.nix</code> which is the source code for the computer, usually importing an auto-generated <code>hardware-configuration.nix</code>. If you install NixOS on several computers, you end up with several of those. They&rsquo;ll tend to have some overlap, on everything from timezones to your preferred dev tooling. If you want to upgrade something, you&rsquo;ll repeat the same process on each computer. If you want to add cross-cutting functionality, well, you get the idea.</p>
<p>The move then is to create a single <code>flake.nix</code> whose schema provides a top-level <code>nixosConfigurations</code>. Your fleet becomes an attrset. Forgiving some pseudo-Nix for the rest of the post:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  desktop <span style="color:#f92672">=</span> { <span style="color:#f92672">...</span> };
</span></span><span style="display:flex;"><span>  laptop <span style="color:#f92672">=</span> { <span style="color:#f92672">...</span> };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>This of course allows you to abstract out shared segments of their definitions:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span><span style="color:#66d9ef">let</span> devTools <span style="color:#f92672">=</span> [ firefox git ]; <span style="color:#66d9ef">in</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  desktop <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    packages <span style="color:#f92672">=</span> devTools <span style="color:#f92672">++</span> [ <span style="color:#f92672">...</span> ];
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  laptop <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    packages <span style="color:#f92672">=</span> devTools <span style="color:#f92672">++</span> [ <span style="color:#f92672">...</span> ];
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>It&rsquo;s a pretty neat way to parameterize any number of machines under a single source of truth!</p>
<p>One of the first steps in <em>Rhizome</em>-hood is to introduce a <em>forge</em>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  vps <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    services<span style="color:#f92672">.</span>forgejo<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Its role is to</p>
<ol>
<li>Host the config repo (among others)</li>
<li>Run CI; in particular, a <code>check.yml</code> that builds all the hosts on every PR. Breaking changes don&rsquo;t make it in.</li>
</ol>
<p>I want the forge highly available, so I host it on my low-resource VPS, and my home laptop, which has the RAM for it, serves as the CI runner.</p>
<p>Notice what that does to the shape. The config is no longer administered from outside the fleet by one special machine with the keys. It is hosted, built, and checked by members of the fleet it describes.</p>
<h2 id="any-node-reaches-any-node">Any Node Reaches Any Node</h2>
<p><a href="https://github.com/slackhq/nebula">Nebula</a> is a nifty tool from Slack that lets you define a network overlay with certificate-based identities.
One well-known node (the &ldquo;lighthouse&rdquo;) acts as a rendezvous point, not a relay. Nodes usually talk directly once they find each other, even from behind NAT.</p>
<p>The VPS is the obvious choice for the lighthouse since it has a stable public IP. Making use of the <code>rec</code> keyword for a recursively evaluated attr-set, we can co-configure the nodes like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span><span style="color:#66d9ef">rec</span> {
</span></span><span style="display:flex;"><span>  vps <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    services<span style="color:#f92672">.</span>nebula<span style="color:#f92672">.</span>networks<span style="color:#f92672">.</span>rhizome <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>      enable <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>      isLighthouse <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  laptop <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    services<span style="color:#f92672">.</span>nebula<span style="color:#f92672">.</span>networks<span style="color:#f92672">.</span>rhizome <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>      enable <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>      lighthouses <span style="color:#f92672">=</span> [ vps ];  <span style="color:#75715e"># really the lighthouse&#39;s overlay IP</span>
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Now every node has a stable address on a flat private subnet, no matter where it physically is. The payoff is that the low-resource VPS never has to build anything. It delegates every Nix build to the laptop over the overlay:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  vps <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    nix<span style="color:#f92672">.</span>settings<span style="color:#f92672">.</span>max-jobs <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;  <span style="color:#75715e"># never build locally</span>
</span></span><span style="display:flex;"><span>    nix<span style="color:#f92672">.</span>buildMachines <span style="color:#f92672">=</span> [{ hostName <span style="color:#f92672">=</span> laptop; sshUser <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;nixremote&#34;</span>; }];
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The same overlay is how I SSH into the laptop from anywhere, with its <code>sshd</code> closed to everything but the Nebula interface. Other tricks follow, like an nginx reverse proxy on the VPS serving websites that actually run at home.</p>
<h2 id="automate-the-updates">Automate the Updates</h2>
<p>Here&rsquo;s where we start to close the loop, via 3 different update modes.</p>
<ol>
<li>Push: Updates to the VPS trigger a CI job wrapping the <code>deploy-rs</code> step.</li>
<li>Pull: Laptops get a systemd timer that checks the forge for main&rsquo;s tip, then builds and switches to that commit.</li>
<li>Self-Bump: Apps running in the fleet are flake inputs. A green merge triggers a version bump in the config repo.</li>
</ol>
<p>All the friction in <a href="/posts/decoupling-deployment-model">Decoupling My Deployment Model</a> just becomes automated CI steps.</p>
<h3 id="push">Push</h3>
<p>The push to the VPS is the <code>deploy-rs</code> command from the earlier posts, run by CI instead of me. It doesn&rsquo;t need to happen on every merge, though, so the workflow only fires when the push asks for it. A commit trailer turns out to be the cheapest &ldquo;deploy this&rdquo; flag there is:</p>
<pre tabindex="0"><code>git commit -m &#34;Tweak nginx&#34; -m &#34;Deploy: vps&#34;
</code></pre><p>The workflow reads it with <code>git interpret-trailers</code> and runs <code>nix run .#deploy-rs -- .#vps</code>. No second system, no deploy button, just a line in the commit message.</p>
<h3 id="pull">Pull</h3>
<p>Laptops don&rsquo;t accept inbound connections, so instead of being pushed to they poll. A systemd timer asks the forge for the tip of <code>main</code>, and if it isn&rsquo;t what&rsquo;s running, switches to exactly that commit:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  laptop <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    systemd<span style="color:#f92672">.</span>timers<span style="color:#f92672">.</span>fleet-switch<span style="color:#f92672">.</span>timerConfig<span style="color:#f92672">.</span>OnCalendar <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;*:0/5&#34;</span>;
</span></span><span style="display:flex;"><span>    systemd<span style="color:#f92672">.</span>services<span style="color:#f92672">.</span>fleet-switch<span style="color:#f92672">.</span>script <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      rev=$(git ls-remote $FORGE/fleet.git main | cut -f1)
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      [ &#34;$rev&#34; = &#34;$(nixos-version --configuration-revision)&#34; ] &amp;&amp; exit 0
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      nixos-rebuild switch --flake &#34;git+$FORGE/fleet.git?rev=$rev#laptop&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#39;&#39;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The comparison works because the flake stamps every build with the commit it came from (<code>system.configurationRevision = self.rev</code>). Pinning the exact revision it observed means a laptop that was asleep for a week catches up to the same commit as everyone else, not to whatever <code>main</code> happens to be mid-build.</p>
<h3 id="self-bump">Self-Bump</h3>
<p>Apps that run on the fleet are flake inputs. Each one&rsquo;s own CI, after a green merge, runs a single step:</p>
<pre tabindex="0"><code>nix run $FORGE/fleet.git#bump -- myapp=$sha
</code></pre><p>That re-pins the input, runs <code>nix flake check</code> with the new lock (a bump the fleet can&rsquo;t build never lands), and pushes to <code>main</code>. A small map in the fleet repo says which hosts a moved pin should deploy:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>rolling <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  myapp <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;laptop&#34;</span> ];
</span></span><span style="display:flex;"><span>  website <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;vps&#34;</span> ];
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>That one line and the step above are all the app and the fleet know about each other. The &ldquo;tab back to the server repo, <code>nix flake update</code>, <code>deploy</code>&rdquo; loop is gone.</p>
<h3 id="tool-bumps">Tool Bumps</h3>
<p>The same trick works pointed at upstream instead of my own repos. Claude Code and Codex are pinned independently of <code>nixpkgs</code>, because the model gate is server-side: a CLI older than a new model locks every agent on the fleet out of it the day it ships. So a daily workflow compares the pins with what Anthropic and npm publish, rewrites them, checks, and pushes with a <code>Deploy: vps</code> trailer:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">on</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">schedule</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">cron</span>: <span style="color:#e6db74">&#34;17 6 * * *&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">jobs</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">bump</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">runs-on</span>: <span style="color:#ae81ff">native</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">steps</span>:
</span></span><span style="display:flex;"><span>      - <span style="color:#f92672">run</span>: <span style="color:#ae81ff">nix run .#bump-tools</span>
</span></span></code></pre></div><p>These show up in <code>main</code>&rsquo;s history:</p>
<pre tabindex="0"><code>6f84965 bump tools: claude-code 2.1.287 -&gt; 2.1.288
5ddc48f bump tools: claude-code 2.1.286 -&gt; 2.1.287, codex 0.159.3 -&gt; 0.160.0
</code></pre><h2 id="yolo-mode-fleet-wide-features">YOLO Mode: Fleet-Wide Features</h2>
<p>With these pieces in place, one PR in one repo can orchestrate cross-cutting features. Every host lives in the same repo, CI builds all of them on every PR, and the merge deploys itself. That makes it a great place to let an agent loose, since a fleet-wide change is a single reviewable diff with a build gate in front of it.</p>
<p>As an example, I asked for an observability system using <a href="https://ntfy.sh">ntfy</a>. The topic name is the whole credential, so it lives in a secret, and the app on my phone subscribes to it.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  vps<span style="color:#f92672">.</span>fleet<span style="color:#f92672">.</span>notify<span style="color:#f92672">.</span>watch<span style="color:#f92672">.</span>laptop <span style="color:#f92672">=</span> { host <span style="color:#f92672">=</span> laptop; port <span style="color:#f92672">=</span> <span style="color:#ae81ff">22</span>; };
</span></span><span style="display:flex;"><span>  laptop<span style="color:#f92672">.</span>fleet<span style="color:#f92672">.</span>notify<span style="color:#f92672">.</span>watch<span style="color:#f92672">.</span>vps <span style="color:#f92672">=</span> { host <span style="color:#f92672">=</span> vps; port <span style="color:#f92672">=</span> <span style="color:#ae81ff">22</span>; };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The hosts probe each other, and report each transition, from reachable to silent and back again. Any service unit can also wire in:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>systemd<span style="color:#f92672">.</span>services<span style="color:#f92672">.</span>backup<span style="color:#f92672">.</span>onFailure <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;notify-failed@%n.service&#34;</span> ];
</span></span></code></pre></div><p>This way, if there&rsquo;s a rupture in the flows, I&rsquo;ll know about it quickly.</p>
<h2 id="the-knots">The Knots</h2>
<p>A rhizome can still tie itself in a loop, and that&rsquo;s where some actual problem-solving went.</p>
<p>A cross-cutting change triggers a re-deploy of the VPS and the laptop. That means the CI runner deploys itself. A switch that restarts the runner kills every job inside it, including the <code>deploy-rs</code> activation of the VPS. Two rules untangle it. The laptop&rsquo;s pull updater drains the runner before it switches, so no new jobs start mid-deploy. And the updater and CI&rsquo;s VPS deploy share a single <code>flock</code>, so replacing the runner has to wait for any in-flight deploy to finish, and vice versa.</p>
<p>Secrets are the other knot, since they&rsquo;re the one thing that can&rsquo;t be declarative. <a href="https://github.com/Mic92/sops-nix">sops-nix</a> gets most of the way: each file is encrypted for my admin key plus the hosts that consume it, so &ldquo;move a service to another host&rdquo; is add a recipient, re-key, commit.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">creation_rules</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">path_regex</span>: <span style="color:#ae81ff">secrets/myapp\.yaml$</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">key_groups</span>: [{ <span style="color:#f92672">age</span>: [ <span style="color:#75715e">*admin,</span> <span style="color:#75715e">*laptop</span> ] }]
</span></span></code></pre></div><p>But the Nebula certificates are still signed by hand from a CA directory and copied out of band. Maybe the rhizome has a root after all. Oops!</p>
<h2 id="summary">Summary</h2>
<p>In my <em>rhizomatic</em> system,</p>
<ul>
<li>the config is a member of the fleet it describes</li>
<li>every node can reach every other</li>
<li><code>main</code> is converged on from three directions</li>
<li>every edge reports its own failures</li>
</ul>
<p>Heterogeneous things connected with no pivot, rupturable anywhere, and a map rather than a tracing. Together they mean I&rsquo;m basically out of the loop, and the commit log on <code>main</code> reads like the fleet maintaining itself.</p>
<p><em>As usual, <a href="https://happysleepy.com/wp-content/uploads/A-Thousand-Plateaus-Drawings-10000BC-49b.jpg">Deleuze and Guattari (illustrated by Marc Ngui and Magda Wojtyra)</a> should help clarify matters.</em></p>
]]></content:encoded>
    </item>
    
  </channel>
</rss>
