<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Parker Jones Dev Blog - remote-builders</title>
    <subtitle>Dev Blog of Parker Jones</subtitle>
    <link rel="self" type="application/atom+xml" href="https://parkerjones.dev/tags/remote-builders/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://parkerjones.dev"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-06-26T00:00:00+00:00</updated>
    <id>https://parkerjones.dev/tags/remote-builders/atom.xml</id>
    <entry xml:lang="en">
        <title>Nix Fleet, Part 2: Remote Builders — Never Compile Linux on a Mac Again</title>
        <published>2026-06-26T00:00:00+00:00</published>
        <updated>2026-06-26T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://parkerjones.dev/posts/nix-fleet-remote-builders/"/>
        <id>https://parkerjones.dev/posts/nix-fleet-remote-builders/</id>
        
        <content type="html" xml:base="https://parkerjones.dev/posts/nix-fleet-remote-builders/">&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Nix Fleet, part 2.&lt;&#x2F;strong&gt; &lt;a href=&quot;&#x2F;posts&#x2F;nix-fleet-one-flake&#x2F;&quot;&gt;Part 1&lt;&#x2F;a&gt; laid out the one-flake-every-machine architecture. This part solves the problem that makes a mixed Linux&#x2F;macOS fleet painful: my laptop can&#x27;t build Linux.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;When you develop NixOS configs on a Mac, you hit a wall fast: an ARM macOS machine cannot build &lt;code&gt;x86_64-linux&lt;&#x2F;code&gt; or &lt;code&gt;aarch64-linux&lt;&#x2F;code&gt; derivations locally. Nothing about Apple Silicon produces Linux binaries. So either you accept that you can only iterate on Linux configs &lt;em&gt;from&lt;&#x2F;em&gt; a Linux machine, or you set up a remote builder and make the problem disappear.&lt;&#x2F;p&gt;
&lt;p&gt;I set up the remote builder. Now my M3 laptop offloads every Linux build to a beefy x86_64 NixOS box, and from where I sit it&#x27;s transparent — &lt;code&gt;nix build&lt;&#x2F;code&gt; just works, the fans on the laptop stay quiet, and the heavy lifting happens on a machine designed for it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-shape-of-the-solution&quot;&gt;The shape of the solution&lt;&#x2F;h2&gt;
&lt;p&gt;There are two roles:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;builder&lt;&#x2F;strong&gt; — my always-on x86_64 NixOS box (SSH alias &lt;code&gt;nixos&lt;&#x2F;code&gt;). It has the cores, the RAM, and the right architecture.&lt;&#x2F;li&gt;
&lt;li&gt;The &lt;strong&gt;controller&lt;&#x2F;strong&gt; — my M3 MacBook. It evaluates the flake and orchestrates, but delegates the actual compilation.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The trick that makes this clean: configure the controller so that it does &lt;strong&gt;zero&lt;&#x2F;strong&gt; local jobs and ships everything to the builder.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;builder-side-accept-work-and-emulate-arm&quot;&gt;Builder side: accept work, and emulate ARM&lt;&#x2F;h2&gt;
&lt;p&gt;The builder needs to be reachable over SSH and willing to build for the architectures I care about. The one non-obvious piece is ARM: I also run an &lt;code&gt;aarch64-linux&lt;&#x2F;code&gt; machine (a &lt;a href=&quot;&#x2F;posts&#x2F;nix-fleet-helios64-disko&#x2F;&quot;&gt;Helios64 NAS&lt;&#x2F;a&gt;), and rather than stand up a separate ARM builder, I let the x86 box build ARM derivations through binfmt&#x2F;QEMU emulation:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;nix&quot;&gt;# on the x86_64 builder
boot.binfmt.emulatedSystems = [ &amp;quot;aarch64-linux&amp;quot; ];
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That one line registers a QEMU user-mode handler so the x86 host transparently runs &lt;code&gt;aarch64&lt;&#x2F;code&gt; build steps. It&#x27;s slower than native ARM (more on that below), but it means &lt;em&gt;one&lt;&#x2F;em&gt; builder covers my entire Linux fleet across both architectures.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;controller-side-offload-everything&quot;&gt;Controller side: offload everything&lt;&#x2F;h2&gt;
&lt;p&gt;On the Mac, I point Nix at the builder and tell it not to build anything itself:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;text&quot;&gt;# &#x2F;etc&#x2F;nix&#x2F;nix.conf (or the equivalent NixOS&#x2F;darwin option)
builders = @&#x2F;etc&#x2F;nix&#x2F;machines
builders-use-substitutes = true
max-jobs = 0
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code data-lang=&quot;text&quot;&gt;# &#x2F;etc&#x2F;nix&#x2F;machines
nixos x86_64-linux &#x2F; - 8 1 kvm,big-parallel
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The magic value is &lt;strong&gt;&lt;code&gt;max-jobs = 0&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;. It tells the local machine to run zero build jobs — so every derivation that isn&#x27;t already in a binary cache &lt;em&gt;must&lt;&#x2F;em&gt; go to a remote builder. There&#x27;s no &quot;sometimes it builds locally and melts the laptop&quot; ambiguity; local builds are simply off. &lt;code&gt;builders-use-substitutes = true&lt;&#x2F;code&gt; lets the builder pull from binary caches directly instead of having the controller ferry everything, which saves a lot of pointless data movement.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;&#x2F;etc&#x2F;nix&#x2F;machines&lt;&#x2F;code&gt; line reads as: builder &lt;code&gt;nixos&lt;&#x2F;code&gt;, builds &lt;code&gt;x86_64-linux&lt;&#x2F;code&gt;, default SSH key, up to &lt;code&gt;8&lt;&#x2F;code&gt; parallel jobs, speed factor &lt;code&gt;1&lt;&#x2F;code&gt;, and it supports the &lt;code&gt;kvm&lt;&#x2F;code&gt; and &lt;code&gt;big-parallel&lt;&#x2F;code&gt; features (so it&#x27;ll accept jobs that need KVM or heavy parallelism).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;verifying-it-actually-offloads&quot;&gt;Verifying it actually offloads&lt;&#x2F;h2&gt;
&lt;p&gt;The failure mode here is silent: you think you&#x27;re offloading and you&#x27;re not. So verify. First, list what the flake even knows how to build:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;nix eval .#nixosConfigurations --apply builtins.attrNames
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then kick off a real Linux build from the Mac and watch &lt;em&gt;the builder&lt;&#x2F;em&gt;, not the laptop:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;nix build .#nixosConfigurations.&amp;lt;host&amp;gt;.config.system.build.toplevel
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If it&#x27;s working, the laptop sits nearly idle while the x86 box lights up. If &lt;code&gt;max-jobs&lt;&#x2F;code&gt; is misconfigured, you&#x27;ll feel it — the Mac will either try (and fail, for Linux) or grind. Watching &lt;code&gt;nproc&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;free -h&lt;&#x2F;code&gt;&#x2F;build load on the builder during a build is the quickest confirmation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-builds-go-wrong&quot;&gt;When builds go wrong&lt;&#x2F;h2&gt;
&lt;p&gt;Offloading introduces its own failure modes, and I keep a short runbook for them:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OOM on the builder&lt;&#x2F;strong&gt;, especially during ARM-emulated builds, which are memory-hungry. Mitigations: serialize with &lt;code&gt;--option max-jobs 1 --option cores 2&lt;&#x2F;code&gt;, or add temporary &lt;code&gt;zram&lt;&#x2F;code&gt; swap:&lt;pre&gt;&lt;code data-lang=&quot;nix&quot;&gt;{ zramSwap = { enable = true; memoryPercent = 50; }; }
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Inspecting a failed derivation&lt;&#x2F;strong&gt; without re-running the whole build:&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;nix log &#x2F;nix&#x2F;store&#x2F;&amp;lt;hash&amp;gt;-&amp;lt;drv&amp;gt;.drv | less -R
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Checking effective settings&lt;&#x2F;strong&gt; when something isn&#x27;t offloading as expected:&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;nix show-config | egrep &amp;#39;^(cores|max-jobs|builders|system-features|substituters)&amp;#39;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-honest-trade-off&quot;&gt;The honest trade-off&lt;&#x2F;h2&gt;
&lt;p&gt;Emulated ARM builds are &lt;em&gt;slow&lt;&#x2F;em&gt; — QEMU user-mode emulation has real overhead, and a big &lt;code&gt;aarch64&lt;&#x2F;code&gt; derivation built on x86 can take noticeably longer than it would native. For my fleet that&#x27;s an acceptable price: I build ARM rarely (the NAS config doesn&#x27;t change often), and the alternative is maintaining a second physical ARM builder. If you&#x27;re iterating heavily on ARM, buy or borrow a native &lt;code&gt;aarch64&lt;&#x2F;code&gt; builder and add it to &lt;code&gt;&#x2F;etc&#x2F;nix&#x2F;machines&lt;&#x2F;code&gt; as a second entry — the controller config doesn&#x27;t otherwise change. The architecture scales to N builders the same way it handles one.&lt;&#x2F;p&gt;
&lt;p&gt;The win is the same one Part 1 was about: I get to live entirely in macOS, drive my whole Linux fleet from there, and never once wait on my laptop to compile something it was never meant to compile. &lt;a href=&quot;&#x2F;posts&#x2F;nix-fleet-agenix-secrets&#x2F;&quot;&gt;Part 3&lt;&#x2F;a&gt; tackles the next fleet-wide concern — secrets — including how a first-boot install fetches them securely over the same SSH trust I just set up here.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;The builder&#x2F;controller config is part of my &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;parallaxisjones&#x2F;dotfiles&quot;&gt;&lt;code&gt;parallaxisjones&#x2F;dotfiles&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; repo.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;— Parker Jones, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;parkerjones.dev&quot;&gt;parkerjones.dev&lt;&#x2F;a&gt;&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
</content>
        
    </entry>
</feed>
