<?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>Systems on Manuel Herrmann</title>
    <link>https://blog.0x17.de/categories/systems/</link>
    <description>Recent content in Systems on Manuel Herrmann</description>
    <image>
      <title>Manuel Herrmann</title>
      <url>https://blog.0x17.de/images/mh.jpg</url>
      <link>https://blog.0x17.de/images/mh.jpg</link>
    </image>
    <generator>Hugo -- 0.165.0</generator>
    <language>en</language>
    <lastBuildDate>Sun, 30 Aug 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://blog.0x17.de/categories/systems/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Two Doors, One Scanner: Running the Creality CR-Scan Pika on NixOS</title>
      <link>https://blog.0x17.de/post/creality-pika-on-nixos/</link>
      <pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate>
      <guid>https://blog.0x17.de/post/creality-pika-on-nixos/</guid>
      <description>How I got a 3D scanner with no Linux build working on NixOS: what actually scans over WiFi under Wine, why USB never will, and the udev rule that fixes VMware passthrough.</description>
      <content:encoded><![CDATA[<p>I spent the first evening certain it was a udev permissions problem. The second, equally
certain it was WinUSB. The third I spent reading <code>OrbbecSDK.dll</code>&rsquo;s import table and
finding out it was neither, and that the thing I had been trying to fix could not be fixed
at all.</p>
<p>The scanner is a Creality CR-Scan Pika. Creality ships CrealityScan for Windows, macOS and
Android, and as of 4.3.1 there is no Linux build and no sign of one coming. The scanner
sits on <code>itw</code>, the desktop I actually work on, and the alternative to solving this was
rebooting into Windows whenever I wanted to scan something. That is the kind of friction
that ends with you not scanning things.</p>
<p>What I ended up with is two paths that each do half the job: WiFi under plain Wine, which
is what actually scans, and USB into a VMware Windows guest, which is the only way to
calibrate. Below is why the split exists, and the NixOS configuration that makes both
reachable.</p>
<p><img alt="intro" loading="lazy" src="/post/creality-pika-on-nixos/intro.jpg"></p>
<h2 id="what-the-pika-actually-is">What the Pika Actually Is</h2>
<p>The most useful thing I learned is what the device looks like to the kernel, because it
rules out most of the theories you would otherwise waste a week on.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Bus 002 Device 006: ID 2bc5:0c0b Orbbec 3D Technology
</span></span></code></pre></div><p>Creality does not build the depth module. It is an Orbbec unit, vendor <code>2bc5</code>, and its
siblings share the vendor ID - the CR-Scan Otter is <code>2bc5:06da</code> - so udev rules written
against the vendor cover the whole family. The app&rsquo;s own config refers to the same device
in decimal (<code>scannerPid 3083</code>, <code>scannerVid &quot;11205&quot;</code>) and internally calls it
<code>SCANNER_RAPTOR_PIKA</code>.</p>
<p>It enumerates as SuperSpeed with five interfaces: <code>1.0</code> through <code>1.3</code> are UVC video
(class <code>0e</code>), and <code>1.4</code> is HID (class <code>03</code>). There is <strong>no vendor-class interface at
all</strong>. This is a plain UVC camera with a HID side channel.</p>
<p>Which means the kernel binds <code>uvcvideo</code> and <code>usbhid</code> to the device on every enumeration,
whether you want it to or not. It means there is no proprietary driver you are missing.
And it means the Windows application reaches the scanner through the Windows camera stack,
which turns out to be the whole problem.</p>
<p><code>bMaxPower</code> is <strong>896 mA</strong>, very nearly the entire SuperSpeed budget. Before blaming
software for a link drop, blame the port. I built a theory on a run of <code>USB disconnect</code>
events with no pattern to them - uptimes between drops from 1m38s to 9m29s - before
working out they were me unplugging the thing. The discriminator for a real fault is a
different port or a self-powered hub, not a config change.</p>
<hr>
<h2 id="packaging-a-windows-unity-app-with-nix">Packaging a Windows Unity App with Nix</h2>
<p>CrealityScan is a 64-bit Unity il2cpp application shipped as a roughly 500 MB Inno Setup
installer. Nothing in it needs to be executed to install it - no kernel drivers, no .NET,
no redistributables - so the derivation is just
<a href="https://github.com/dscharrer/innoextract">innoextract</a> and a wrapper script.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>installPhase <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  runHook preInstall
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  innoextract --silent --extract --output-dir . $src
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  mkdir -p $out/share/creality-scan
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  cp -r app/. $out/share/creality-scan/
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  mkdir -p $out/bin
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  substitute </span><span style="color:#e6db74">${</span><span style="color:#e6db74">./creality-scan.sh</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> $out/bin/creality-scan \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    --subst-var-by app &#34;$out/share/creality-scan&#34; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    --subst-var-by wine &#34;</span><span style="color:#e6db74">${</span>winePackage<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34; \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    --subst-var-by dxvk &#34;</span><span style="color:#e6db74">${</span>dxvk<span style="color:#f92672">.</span>bin<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  chmod +x $out/bin/creality-scan
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  runHook postInstall
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#39;&#39;</span>;
</span></span></code></pre></div><p><code>substitute</code> rather than <code>makeWrapper</code> because the launcher is not just an exec line: it
initialises the Wine prefix, installs DXVK into it, and sets <code>WINEDLLOVERRIDES</code>, which
reads better as a script than as a pile of <code>--set</code> flags. The one payload file worth naming
now is <code>CrealityScan_Data/Plugins/x86_64/OrbbecSDK.dll</code>, because the next two sections are
about what is inside it.</p>
<p>There is one trap in the derivation&rsquo;s signature:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>{ lib<span style="color:#f92672">,</span> stdenvNoCC<span style="color:#f92672">,</span> fetchurl<span style="color:#f92672">,</span> innoextract<span style="color:#f92672">,</span> dxvk<span style="color:#f92672">,</span> <span style="color:#f92672">...</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">,</span> wine64Packages
</span></span><span style="display:flex;"><span><span style="color:#f92672">,</span> winePackage <span style="color:#f92672">?</span> wine64Packages<span style="color:#f92672">.</span>stagingFull
</span></span><span style="display:flex;"><span>}:
</span></span></code></pre></div><p>The argument is called <code>winePackage</code>, not <code>wine</code>, and deliberately so. <code>callPackage</code> fills
arguments by name from <code>pkgs</code>, so an argument named <code>wine</code> would silently get <code>pkgs.wine</code> -
which is the 32-bit build. The app is 64-bit only. Nothing fails at build time either,
because the Wine path is only substituted into a script; you find out when you launch it.
Naming it <code>winePackage</code> keeps <code>callPackage</code> out of the decision and leaves the default
explicit and overridable.</p>
<hr>
<h2 id="the-udev-rule-that-has-to-load-before-73">The udev Rule That Has to Load Before 73</h2>
<p>The application needs raw access to the USB device node. The obvious rule grants 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;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>SUBSYSTEM==&#34;usb&#34;, ATTR{idVendor}==&#34;2bc5&#34;, MODE=&#34;0660&#34;, TAG+=&#34;uaccess&#34;
</span></span></code></pre></div><p><code>TAG+=&quot;uaccess&quot;</code> is the standard way to hand a device to whoever is logged in at the seat.
Written the obvious way in NixOS:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>services<span style="color:#f92672">.</span>udev<span style="color:#f92672">.</span>extraRules <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  SUBSYSTEM==&#34;usb&#34;, ATTR{idVendor}==&#34;2bc5&#34;, MODE=&#34;0660&#34;, TAG+=&#34;uaccess&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">&#39;&#39;</span>;
</span></span></code></pre></div><p>And it does not work. The device node stays <code>root:root 0660</code>, and the app reports
<code>usb disconnect / connect to device failed!</code> with the scanner plainly plugged in and
plainly visible in <code>lsusb</code>.</p>
<p>The reason is ordering. <code>TAG+=&quot;uaccess&quot;</code> does not itself grant anything - it is a marker.
What turns the tag into an actual POSIX ACL is systemd&rsquo;s <code>73-seat-late.rules</code>, which
carries <code>RUN{builtin}+=&quot;uaccess&quot;</code>. udev processes rule files in lexical order, and
<code>services.udev.extraRules</code> lands everything in <code>99-local.rules</code>. At 99, the tag is set
<em>after</em> 73 has already looked for it. Nothing consumes it, so nothing happens.</p>
<p>The fix is to ship the rules as a udev <em>package</em>, with a filename that sorts before 73:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>services<span style="color:#f92672">.</span>udev<span style="color:#f92672">.</span>packages <span style="color:#f92672">=</span> [
</span></span><span style="display:flex;"><span>  (pkgs<span style="color:#f92672">.</span>writeTextDir <span style="color:#e6db74">&#34;lib/udev/rules.d/60-creality-scan.rules&#34;</span> <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    SUBSYSTEM==&#34;usb&#34;, ATTR{idVendor}==&#34;2bc5&#34;, MODE=&#34;0660&#34;, TAG+=&#34;uaccess&#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></code></pre></div><p><img alt="udev rule ordering" loading="lazy" src="/post/creality-pika-on-nixos/udev-order.png"></p>
<p>Same rule, different filename, and now it works. This applies to anything that relies on
<code>uaccess</code> on NixOS, not just this scanner, and the failure mode gives you no hint at all
about ordering.</p>
<p>Validate before switching rather than after:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>udevadm verify /nix/store/.../60-creality-scan.rules
</span></span><span style="display:flex;"><span>udevadm test --action<span style="color:#f92672">=</span>add /sys/bus/usb/devices/2-1 2&gt;&amp;<span style="color:#ae81ff">1</span> | grep creality
</span></span></code></pre></div><p><code>udevadm test</code> simulates the <code>add</code> uevent without touching the device. The line to look for
is <code>Running in test mode, skipping writing &quot;0&quot; to sysfs attribute</code> - that is the rule
announcing it <em>matched</em>, and without it you have a rule that never fired.</p>
<p>And <strong>udev rule changes take effect on the next plug</strong>, not at switch time. A device that
is already attached keeps whatever it had, and nothing tells you so.</p>
<hr>
<h2 id="why-usb-under-wine-is-a-dead-end">Why USB Under Wine Is a Dead End</h2>
<p>With the udev rule in place, Wine&rsquo;s <code>wineusb</code> genuinely opens the device and creates the
PDOs. A <code>WINEDEBUG=+setupapi</code> trace shows the application walking the USB enumerator and
finding exactly what it should:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>VID_2BC5&amp;PID_0C0B
</span></span><span style="display:flex;"><span>MI_00 MI_01 MI_02 MI_03 MI_04
</span></span></code></pre></div><p>It finds the scanner, and then reports &ldquo;No device found&rdquo;. That contradiction is what sent
me into the DLL.</p>
<p><code>OrbbecSDK.dll</code> reaches the device <strong>purely through Media Foundation</strong>. Its import table
is <code>MFEnumDeviceSources</code>, <code>MFCreateDeviceSource</code> and
<code>MFCreateSourceReaderFromMediaSource</code>, plus SetupAPI and CfgMgr32 for enumeration. It
imports <strong>no <code>winusb.dll</code> at all</strong>. The <code>WinUsb_*</code> and <code>libusb</code> strings you will find
inside the binary, and which sent me down the WinUSB path in the first place, are dead
code from a statically linked libusb that this device never uses.</p>
<p>So it is not a permissions problem, not a WinUSB problem, and not an isochronous transfer
problem. It is one function:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>fixme:mfplat:MFEnumDeviceSources Not implemented for video capture devices
</span></span></code></pre></div><p>Wine implements <code>MFEnumDeviceSources</code> for <strong>audio</strong> capture only. For video it is a stub.
The SetupAPI half of the puzzle works fine, the application walks right up to the device,
asks Media Foundation for a video source, gets nothing, and gives up. Remember the device
is a plain UVC camera with no vendor interface - the camera stack is the only door it has.</p>
<p><img alt="the wine usb stack and where it stops" loading="lazy" src="/post/creality-pika-on-nixos/wine-usb.png"></p>
<p>Closing that gap means implementing real camera support in
<a href="https://www.winehq.org/">Wine</a>: an <code>IMFMediaSource</code> for capture,
<code>KSCATEGORY_VIDEO_CAMERA</code> device interfaces, and <code>IKsControl</code> over UVC extension units for
the vendor channel. An out-of-tree patch set exists at
<a href="https://github.com/poita66/wine">poita66/wine</a> on branch <code>test/all-prs</code>, around 850
lines. I read it hoping for a shortcut. Its media source only <em>enumerates</em>: <code>Start()</code> is a
no-op and the presentation descriptor is hardcoded, so it finds the device and never
delivers a frame.</p>
<p>For USB, the answer is a Windows guest. I would rather have written a different sentence
here, but that is where the evidence lands, and knowing it is settled is worth something
on its own.</p>
<hr>
<h2 id="the-path-that-scans-wifi-in-a-network-namespace">The Path That Scans: WiFi in a Network Namespace</h2>
<p>Scanning works over WiFi under plain Wine, with live depth reconstruction, and that is the
path I use daily.</p>
<p>The Pika raises its own access point, SSID <code>Pika_&lt;serial&gt;</code>, with the WPA passphrase
printed on the device. CrealityScan finds it with GigE-Vision discovery - GVCP, a UDP
broadcast - and then drives the scanner over a vendor TCP link. No Wine USB stack is
involved anywhere in that path, which is precisely why it works. The Media Foundation wall
is a USB-camera wall; over the network the app speaks a protocol Wine never has to
understand.</p>
<p>On a clean laptop you connect to the scanner&rsquo;s AP and run the app. On <code>itw</code> it did not
work, and the reason is a nice piece of Windows-socket pedantry.</p>
<p>CrealityScan&rsquo;s GVCP client binds a discovery socket to <strong>every</strong> local interface and
broadcasts on each one. <code>itw</code> has VPN and Tailscale interfaces: point-to-point TUNs with
no broadcast capability, one of them a zero-MAC <code>wt0</code>. Broadcasting on those fails with
<code>WSAENOTSOCK</code> (10038), and the failures drown discovery. The scanner is never found, and
the app tells you nothing useful about why.</p>
<p>The fix is not to tear down the VPN every time you want to scan something. It is to run
the application in a network namespace that contains only a dedicated WiFi dongle, so the
noisy interfaces are not visible to it at all:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>ox<span style="color:#f92672">.</span>crealityScan<span style="color:#f92672">.</span>wifiIsolation<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span></code></pre></div><p>(<code>ox.*</code> is my own option namespace, not anything upstream - substitute your own prefix.)</p>
<p>This is opt-in per host, and correctly so. It only makes sense where there is a spare radio
to hand over. A laptop whose single radio is its uplink should just run <code>creality-scan</code>
normally and let the scanner join the LAN.</p>
<p>The launcher builds the namespace, moves the radio in, associates, and gets a lease:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>ip netns add <span style="color:#e6db74">&#34;</span>$CS_NETNS<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>iw phy <span style="color:#e6db74">&#34;</span>$phy<span style="color:#e6db74">&#34;</span> set netns name <span style="color:#e6db74">&#34;</span>$CS_NETNS<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>ip netns exec <span style="color:#e6db74">&#34;</span>$CS_NETNS<span style="color:#e6db74">&#34;</span> ip link set lo up
</span></span><span style="display:flex;"><span>ip netns exec <span style="color:#e6db74">&#34;</span>$CS_NETNS<span style="color:#e6db74">&#34;</span> ip link set <span style="color:#e6db74">&#34;</span>$dev<span style="color:#e6db74">&#34;</span> up
</span></span><span style="display:flex;"><span>ip netns exec <span style="color:#e6db74">&#34;</span>$CS_NETNS<span style="color:#e6db74">&#34;</span> wpa_supplicant -B -i <span style="color:#e6db74">&#34;</span>$dev<span style="color:#e6db74">&#34;</span> -c <span style="color:#e6db74">&#34;</span>$conf<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>ip netns exec <span style="color:#e6db74">&#34;</span>$CS_NETNS<span style="color:#e6db74">&#34;</span> dhcpcd -4 -q -t <span style="color:#ae81ff">20</span> <span style="color:#e6db74">&#34;</span>$dev<span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>Bringing <code>lo</code> up is not decoration: a fresh namespace has loopback down, and things fail
strangely without it. The launcher matches the AP by prefix rather than pinning a serial,
so the config survives a replacement scanner, and the passphrase comes from a
<a href="https://github.com/Mic92/sops-nix">sops-nix</a> secret rather than the script.</p>
<p>Three things I got wrong on the way to that working.</p>
<p><strong>Moving a radio is per-<code>phy</code>, not per-interface.</strong> <code>iw phy ... set netns</code> takes the whole
physical device. If your dongle exposes more than one netdev they all move together, and
the host loses all of them at once.</p>
<p><strong>A dead run leaves the radio inside the namespace.</strong> If a previous launch died without
tearing down, the dongle is simply invisible to the host and every diagnostic you run is
misleading. There is a <code>--down</code> flag for exactly this, and it is the first thing to run
before debugging anything else.</p>
<p><strong>The privilege boundary eats your session environment.</strong> The launcher re-execs itself
through <code>sudo</code> for the namespace setup, then drops back to the invoking user for the GUI.
Get that wrong and the app inherits <em>root&rsquo;s</em> XDG directories, which produced my favourite
bug of the whole project. The app shells out to <code>xdg-open</code> for the Creality OAuth login
page. <code>HOME</code> was correct, so <code>~/.config/mimeapps.list</code> and its
<code>x-scheme-handler/https=firefox.desktop</code> pin were read as intended. But <code>xdg-mime</code> only
honours a default whose <code>.desktop</code> file it can actually locate, and Firefox is installed
per-user in <code>~/.nix-profile/share/applications</code>, which is not on root&rsquo;s search path. The
pin was silently discarded, lookup fell through to the system mimeinfo cache, and my login
page opened in some other browser entirely. So the launcher now carries <code>--display</code>,
<code>--wayland</code>, <code>--runtimedir</code>, <code>--datadirs</code> and <code>--configdirs</code> across the boundary
explicitly.</p>
<p>There is a NixOS-specific consequence of that <code>sudo</code> re-exec worth knowing about. The
nopasswd rule has to target the launcher&rsquo;s exact store path, so the rule and the script are
generated together:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>security<span style="color:#f92672">.</span>sudo<span style="color:#f92672">.</span>extraRules <span style="color:#f92672">=</span> [ (oxutils<span style="color:#f92672">.</span>sudo<span style="color:#f92672">.</span>nopasswd wifiLauncher <span style="color:#e6db74">&#34;creality-scan-wifi&#34;</span>) ];
</span></span></code></pre></div><p>Change one character in the shell script and its derivation hash changes, so the rule must
be regenerated with it or the launcher stops being able to elevate. Deriving the rule from
the package rather than hardcoding a path keeps the two in step automatically.</p>
<p>One last environment detail: pin the discrete GPU&rsquo;s Vulkan ICD, or DXVK quietly falls back
to llvmpipe and you will blame the scanner for being slow.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>icd<span style="color:#f92672">=</span>/run/opengl-driver/share/vulkan/icd.d/nvidia_icd.json
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> -e <span style="color:#e6db74">&#34;</span>$icd<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span> env_args<span style="color:#f92672">+=(</span><span style="color:#e6db74">&#34;VK_ICD_FILENAMES=</span>$icd<span style="color:#e6db74">&#34;</span><span style="color:#f92672">)</span>; <span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><hr>
<h2 id="dxvk-and-opencl-have-to-coexist">DXVK and OpenCL Have to Coexist</h2>
<p>Connecting is not the same as scanning. The live depth reconstruction has two graphics
requirements that pull against each other.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>export WINEARCH<span style="color:#f92672">=</span>win64
</span></span><span style="display:flex;"><span>export WINEDLLOVERRIDES<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>WINEDLLOVERRIDES<span style="color:#66d9ef">:-</span>mscoree,mshtml=;d3d11,dxgi=n<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p><code>mscoree,mshtml=</code> disables the Mono and Gecko install prompts. <code>d3d11,dxgi=n</code> selects
native DLLs, which is how <a href="https://github.com/doitsujin/dxvk">DXVK</a> gets loaded.</p>
<p><strong>DXVK is required.</strong> Wine&rsquo;s builtin <code>wined3d</code> renders the UI perfectly well, which is
exactly what makes this confusing - everything looks fine until you press the button that
starts a scan, at which point the D3D11 load spikes and it crashes.</p>
<p><strong>Host OpenCL is required</strong>, separately, because the Orbbec depth engine uses it.</p>
<p><strong>Proton does not work</strong>, even though Proton bundles DXVK and is the obvious thing to reach
for. Its Steam runtime container hides the host NVIDIA OpenCL ICD, so the application
enumerates 0 GPU devices and the scan fails. That it fails differently from the <code>wined3d</code>
crash is the useful part: two distinct failure modes meant two distinct requirements.</p>
<p><strong>Plain Wine plus DXVK gives both</strong>, and it is the only combination that scans. The Wine
launcher installs the bundled DXVK DLLs into the prefix idempotently, keyed on the DXVK
store path:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#66d9ef">if</span> <span style="color:#f92672">[</span> <span style="color:#e6db74">&#34;</span><span style="color:#66d9ef">$(</span>cat <span style="color:#e6db74">&#34;</span>$WINEPREFIX<span style="color:#e6db74">/.dxvk-version&#34;</span> 2&gt;/dev/null <span style="color:#f92672">||</span> true<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span> !<span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;</span>$dxvk<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> f in d3d11.dll dxgi.dll d3d10core.dll; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    install -m644 <span style="color:#e6db74">&#34;</span>$dxvk<span style="color:#e6db74">/x64/</span>$f<span style="color:#e6db74">&#34;</span> <span style="color:#e6db74">&#34;</span>$WINEPREFIX<span style="color:#e6db74">/drive_c/windows/system32/</span>$f<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    install -m644 <span style="color:#e6db74">&#34;</span>$dxvk<span style="color:#e6db74">/x32/</span>$f<span style="color:#e6db74">&#34;</span> <span style="color:#e6db74">&#34;</span>$WINEPREFIX<span style="color:#e6db74">/drive_c/windows/syswow64/</span>$f<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span>  printf <span style="color:#e6db74">&#39;%s&#39;</span> <span style="color:#e6db74">&#34;</span>$dxvk<span style="color:#e6db74">&#34;</span> &gt; <span style="color:#e6db74">&#34;</span>$WINEPREFIX<span style="color:#e6db74">/.dxvk-version&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">fi</span>
</span></span></code></pre></div><p>Keying on the store path means a DXVK upgrade refreshes the prefix automatically and a
re-run does nothing. The application itself lives read-only in the Nix store and is started
through the <code>Z:</code> drive, so the prefix holds only Wine state, DXVK and the app&rsquo;s own
AppData.</p>
<p>If the scanner connects and then fails at scan start, it is a graphics or OpenCL problem,
not a network one.</p>
<hr>
<h2 id="calibration-needs-a-real-windows-guest">Calibration Needs a Real Windows Guest</h2>
<p>You cannot calibrate over WiFi, and this is not a Wine limitation or a Linux limitation.
The device says so itself:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#e6db74">&#34;IsCalibratable&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> <span style="color:#66d9ef">true</span><span style="color:#960050;background-color:#1e0010">,</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;IsWifiCalibratable&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> <span style="color:#66d9ef">false</span><span style="color:#960050;background-color:#1e0010">,</span>
</span></span><span style="display:flex;"><span><span style="color:#e6db74">&#34;IsUpdatable&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> <span style="color:#66d9ef">true</span><span style="color:#960050;background-color:#1e0010">,</span>
</span></span></code></pre></div><p>Calibration is USB-only by device policy. Since USB under Wine is settled, that means a
Windows guest, which on my enduser hosts is on by default:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>ox<span style="color:#f92672">.</span>vmware<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>enduser;
</span></span></code></pre></div><p>That is a thin wrapper over the upstream <code>virtualisation.vmware.host.enable</code>, pulling in
the <code>vmmon</code> and <code>vmnet</code> modules and <code>vmware-usbarbitrator</code>, the piece that actually does
USB passthrough. Workstation Pro has been free for commercial, educational and personal use
since 17.5.2 in November 2024, so there is no license key involved.</p>
<p>VMware rather than QEMU for one specific reason: its SVGA3D gives the guest a genuine D3D11
device backed by the host GPU. CrealityScan is a Unity D3D11 application and would
otherwise land on Windows&rsquo; WARP software renderer.</p>
<p>The caveat, before you get ambitious: <strong>the VMware vGPU exposes no OpenCL</strong>, and the Orbbec
depth engine needs it. This is the same wall Proton hit, arrived at from a completely
different direction. Firmware upgrade does not touch the depth engine; live depth
reconstruction does. Do not expect to scan in the guest. The guest is for calibration and
firmware, and that is all.</p>
<h3 id="the-set_configuration-race">The <code>SET_CONFIGURATION</code> Race</h3>
<p>The Pika is a plain UVC device, so on <strong>every</strong> enumeration the kernel binds <code>uvcvideo</code> to
interfaces 0 and 2 and <code>usbhid</code> to interface 4. This happens at device registration, before
udev runs and well before VMware gets anywhere near it. VMware then force-detaches them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>USBGL: Failed to claim device interface(0), retrying.
</span></span><span style="display:flex;"><span>USBGL: Disconnecting driver &#39;uvcvideo&#39; from interface(0).
</span></span><span style="display:flex;"><span>USBGL: Disconnecting driver &#39;uvcvideo&#39; from interface(2).
</span></span><span style="display:flex;"><span>USBGL: Disconnecting driver &#39;usbhid&#39;   from interface(4).
</span></span></code></pre></div><p>That mostly works. What it loses is the one thing that matters. While the interfaces are
still claimed, the guest&rsquo;s <code>SET_CONFIGURATION</code> is refused by the host kernel, and VMware
<strong>fakes the result</strong>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>kernel: usb 2-1: usbfs: interface 0 claimed by usbfs while &#39;vmx-vcpu-1&#39; sets config #1
</span></span><span style="display:flex;"><span>vmx:    USBGL: Failed to set device to config(1): work around triggered.
</span></span></code></pre></div><p>Windows now believes it configured the device. The host never re-applied the configuration.
Control transfers still work, so enumeration succeeds, the device shows up in Device
Manager, CrealityScan sees it, and firmware chatter goes back and forth quite happily.
Sustained streaming stalls. Everything you would normally check to confirm the passthrough
is healthy reports that it is healthy.</p>
<p>The fix is to make sure no host driver ever holds the device: unconfigure it on plug, so
VMware receives it clean and its <code>SET_CONFIGURATION</code> actually lands.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>ACTION==&#34;add&#34;, SUBSYSTEM==&#34;usb&#34;, ENV{DEVTYPE}==&#34;usb_device&#34;, ATTR{idVendor}==&#34;2bc5&#34;, ATTR{bConfigurationValue}=&#34;0&#34;
</span></span></code></pre></div><p>Shipped in the same <code>60-</code> file as the <code>uaccess</code> rule, behind
<code>ox.crealityScan.udevRules.reserveForGuest</code>, which defaults to on.</p>
<p><img alt="the set_configuration race with and without the rule" loading="lazy" src="/post/creality-pika-on-nixos/setconfig-race.png"></p>
<p>Three mechanics behind that one line:</p>
<p><strong><code>driver_override</code> is not available here.</strong> USB <em>interfaces</em> do not expose it - only PCI
and platform buses do, which I confirmed on kernel 6.18. Unconfiguring the whole device is
the route that actually exists.</p>
<p><strong>udev cannot preempt the kernel&rsquo;s driver binding.</strong> Binding happens at device
registration, before any uevent is delivered, so no rule can get there first. The rule
tears the binding down immediately afterwards instead, which is early enough.</p>
<p><strong>The <code>ENV{DEVTYPE}==&quot;usb_device&quot;</code> guard is belt and braces.</strong> Interfaces carry the IDs as
<code>ATTRS{}</code> rather than <code>ATTR{}</code> and would not match anyway, but being explicit costs
nothing.</p>
<p>This does cost something, knowingly: with no configuration there are no interfaces, so
<code>wineusb</code>&rsquo;s SetupAPI enumeration breaks. Given that path only ever got as far as creating
PDOs before dying at Media Foundation, it buys nothing and I gave it up without regret. The
<code>uaccess</code> half of the rule stays, because <code>vmx</code> runs as the desktop user and wants the
usbdev node.</p>
<h3 id="verifying-it-and-the-trap-in-verifying-it">Verifying It, and the Trap in Verifying It</h3>
<p><strong>Do not check sysfs after the guest has attached the device.</strong> Once VMware connects and
the guest sets the configuration, you will see <code>bConfigurationValue=1</code> and five interfaces
bound to <code>usbfs</code>. That is the expected success state, not a failure, and it looks exactly
like the bug. Checking before the guest attaches is also unreliable: the window between
enumeration and VMware attach can be as little as a second, even though the kernel&rsquo;s own
driver binding beat it there by a wide margin.</p>
<p>The reliable evidence is in <code>vmware.log</code>, next to the <code>.vmx</code>. Success looks 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;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>USBGL: Ignoring claim interface failure: interface(0) doesn&#39;t exist.
</span></span><span style="display:flex;"><span>USBGL: Connected to device successfully.
</span></span><span style="display:flex;"><span>USBGL: Claimed device interface(0..4) successfully.
</span></span></code></pre></div><p><code>interface(N) doesn't exist</code> is the signal you want. It means the device arrived
unconfigured. Three failure signatures should all sit at zero:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>grep -c <span style="color:#e6db74">&#39;work around triggered&#39;</span>   vmware.log      <span style="color:#75715e"># was 1 per attach</span>
</span></span><span style="display:flex;"><span>grep -c <span style="color:#e6db74">&#39;Disconnecting driver&#39;</span>    vmware.log      <span style="color:#75715e"># was 3 per attach</span>
</span></span><span style="display:flex;"><span>journalctl -k | grep -c <span style="color:#e6db74">&#39;claimed by usbfs while&#39;</span>  <span style="color:#75715e"># was 1 per attach</span>
</span></span></code></pre></div><p><code>uvcvideo</code> and <code>hid-generic</code> lines still appearing in the kernel log at plug time are
expected and harmless - the kernel binds before udev runs, and udev tears it down. Their
presence is not evidence of failure. VMware finding no interfaces is evidence of success.</p>
<p>To find out who owns a device, ask the arbitrator rather than the process table:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>journalctl -u vmware-usbarbitrator | grep -i 2bc50c0b
</span></span><span style="display:flex;"><span><span style="color:#75715e"># owner:Windows 11 x64</span>
</span></span></code></pre></div><p><code>vmx</code> runs as the desktop user, so its <code>/proc/&lt;pid&gt;/fd</code> is not readable by other users and
an empty <code>fuser</code> or <code>lsof</code> result proves nothing at all.</p>
<hr>
<h2 id="settingsdat-is-plain-json"><code>settings.dat</code> Is Plain JSON</h2>
<p>CrealityScan gates calibration and firmware upgrade behind a device performance test run by
its <code>PerformanceCheckService</code>. In the VMware guest that test hangs, which puts both of the
things the guest exists for out of reach. The log is at least honest about what it is
looking for:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>[Warning] Key &#39;PerformanceTestDevices&#39; not found in settings.
</span></span></code></pre></div><p>The application&rsquo;s configuration lives at
<code>%LOCALAPPDATA%\Creality\CrealityScan\settings.dat</code>, and despite the extension it is plain
JSON, unencrypted and directly editable. Seeding a <code>PerformanceTestDevices</code> entry for the
scanner&rsquo;s serial makes the app skip the test, which I verified works. Close the application
first - it rewrites <code>settings.dat</code> on exit and will clobber the edit.</p>
<p>If the scanner has ever run against another machine, including the Wine prefix on the host,
that <code>settings.dat</code> already holds a genuine entry. Copy it rather than hand-building one,
adjusting <code>connectionType</code> (the literals are <code>&quot;USB&quot;</code> and <code>&quot;Wi-Fi&quot;</code>) and the matching
<code>IsUsb*</code> and <code>IsWifi*</code> booleans.</p>
<p>The app&rsquo;s state on disk is the best diagnostic surface it has, and it is split across two
directories that are easy to confuse. <code>settings.dat</code> and <code>Logs\&lt;timestamp&gt;\</code> sit under
<code>%LOCALAPPDATA%\Creality\CrealityScan\</code>; the Unity log, the downloaded firmware payload and
<code>DataCache\</code> sit under <code>%LOCALAPPDATA%Low\...</code>, which is <code>AppData\LocalLow</code>, a different
path entirely.</p>
<p><code>device_info.json</code>, in the newest log directory, gives you the device&rsquo;s own view:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{<span style="color:#f92672">&#34;deviceName&#34;</span>:<span style="color:#e6db74">&#34;Creality Pika&#34;</span>,<span style="color:#f92672">&#34;serialNumber&#34;</span>:<span style="color:#e6db74">&#34;...&#34;</span>,<span style="color:#f92672">&#34;firmwareVersion&#34;</span>:<span style="color:#e6db74">&#34;1.1.1&#34;</span>}
</span></span></code></pre></div><p>and <code>DataCache\DownloadFirmware_SCANNER_RAPTOR_PIKA</code> over in LocalLow tells you what the
OTA server offered. Reading both together resolved a line that had me stuck for a while:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>[FirmwareUpgradeManager] PreDownloadAsync#SCANNER_RAPTOR_PIKA: local firmware is up to date, skipping.
</span></span></code></pre></div><p>That refers to the <strong>downloaded payload</strong> being current, not the device. A scanner on 1.1.1
with 1.1.10 already sitting in the cache produces exactly that message.</p>
<p>If the upgrade prompt never appears at all, there is a second key to check, misspelling and
all: <code>DonnotRemidDevicePidFwVersion_SCANNER_RAPTOR_PIKA</code>. It is a &ldquo;don&rsquo;t remind me&rdquo; list,
and an empty <code>versions</code> array in it means nothing is suppressed. If the version being
offered is listed there, clearing the array brings the prompt back.</p>
<p>To be clear about what the <code>PerformanceTestDevices</code> edit is: it bypasses a check the
application performs on itself. That is fine on my own hardware and it is what makes
calibration reachable when the check is broken in a VM. It does not make the guest meet the
requirements the check was testing for. There is still no OpenCL in there.</p>
<hr>
<h2 id="is-the-scanner-actually-any-good">Is the Scanner Actually Any Good?</h2>
<p>Everything above is about making the thing work, which is not the same question as whether
it was worth making work.</p>
<p>So far, yes. The first real job was a cradle to hold a remote control under the edge of my
desk, and it went from scan to printed part without a fight. Here is the scan mesh dropped
into FreeCAD with the sleeve modelled around it:</p>
<p><img alt="freecad with the scan mesh" loading="lazy" src="/post/creality-pika-on-nixos/freecad-scan.png"></p>
<p>That picture is the entire argument for owning a scanner. The remote is compound curves and
soft tapers all the way round, and measuring it into a snug fit by hand would have been an
afternoon with calipers and a lot of guessing. With the mesh in the document you model
against the object itself. It also shows honestly what the scan gives you: the button pads
come through as distinct raised shapes and the overall form is right, with some noise
across the flat areas that costs nothing for a part which only has to clear the surface.</p>
<p>Printed, it holds:</p>
<p><img alt="the remote slid halfway out of its printed cradle" loading="lazy" src="/post/creality-pika-on-nixos/remote-holder.jpg"></p>
<p>It grips firmly enough to hold the remote half extended, which turns out to be the useful
part: turning the light on means sliding it down and pushing it back, rather than pulling
it out and finding somewhere to set it down. That is a tolerance question, and tolerance is
what hand measurement gets wrong on a rounded object - slightly loose and it drops out,
slightly tight and it will not slide at all.</p>
<p>It is early days, so read that as a first impression rather than a settled verdict.</p>
<p>My one real complaint is not technical. CrealityScan&rsquo;s interface is big buttons and color
everywhere, the visual language of a phone app that happens to open on a desktop. Nothing
about it is broken and it works fine once you know where things are - I would just rather
have had something compact and purely functional, tool density instead of kiosk
friendliness. That is taste rather than a defect, and it is the one part of this whole
exercise I cannot fix with a udev rule.</p>
<hr>
<h2 id="the-hard-parts">The Hard Parts</h2>
<p><strong>No single path does everything.</strong> Scanning is WiFi under Wine. Calibration is USB into a
Windows guest. There is no configuration where one of them covers both, and switching
between them is a physical act involving the scanner&rsquo;s mode and a dongle. If that sounds
tiring, it is.</p>
<p><strong>Firmware over WiFi is an open question.</strong> The device declares <code>IsCalibratable</code> and
<code>IsWifiCalibratable: false</code>, so calibration is unambiguous. For updates there is only
<code>IsUpdatable: true</code> and no <code>IsWifiUpdatable</code> flag exists at all, which suggests firmware
over WiFi may well work and would skip the VM entirely. <strong>I have not tested it.</strong> I would
rather leave that marked open than guess at it, and if it turns out to work then the note in
my own module is too broad and should say calibration only.</p>
<p><strong><code>reserveForGuest</code> deliberately breaks something.</strong> Unconfiguring the device on plug means
the host cannot open the scanner as a plain UVC camera either. That is the right default
here because nothing on my Linux side wants to, but if you have something native that does,
turn it off.</p>
<p><strong>udev changes need a replug</strong>, not a rebuild. Nothing warns you.</p>
<p><strong>Who this is not for.</strong> If you have one machine, one radio, no appetite for running a
Windows VM, and you scan occasionally, dual-booting is not the wrong answer.</p>
<hr>
<h2 id="where-this-leads">Where This Leads</h2>
<p>The whole split comes down to one missing piece of software. If Wine gains real Media
Foundation video capture - an <code>IMFMediaSource</code> that actually starts,
<code>KSCATEGORY_VIDEO_CAMERA</code> interfaces, <code>IKsControl</code> over UVC extension units - then USB works
under Wine, calibration stops needing a Windows guest, and both paths collapse into one. The
out-of-tree patches show the shape of it, but enumeration without frame delivery is the easy
nine tenths.</p>
<p>Three threads I would pull next, in order of how much they would actually change:</p>
<ol>
<li><strong>Test firmware upgrade over WiFi.</strong> Cheapest experiment here, and it would either remove
the VM from the firmware path or settle the question properly.</li>
<li><strong>Try the scanner as a plain UVC camera on the host.</strong> It is a UVC device with no vendor
interface, which means <code>v4l2</code> should see something with <code>reserveForGuest</code> off. Whether the
depth stream is usable without the Orbbec SDK is a different matter, but nobody has to
guess - the interfaces are right there.</li>
<li><strong>Push on the Wine side.</strong> The failing call is named, the patch set exists, and the gap
between &ldquo;enumerates&rdquo; and &ldquo;delivers frames&rdquo; is well defined.</li>
</ol>
<p>My NixOS repo is private, so there is no clone link to give you, but there is nothing in it
this post has not quoted: a derivation that runs <code>innoextract</code> and substitutes store paths
into a launcher, a module emitting two udev rules and a netns wrapper, and a few lines
turning on VMware. The three things that cost me the most time are the cheapest to get
right: name the udev file <code>60-</code>, put DXVK on plain Wine rather than Proton, and do not go
looking for WinUSB.</p>
]]></content:encoded>
    </item>
    <item>
      <title>One Git Repo to Rule Them All: Managing 12 Machines with NixOS</title>
      <link>https://blog.0x17.de/post/managing-12-machines-with-nixos/</link>
      <pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate>
      <guid>https://blog.0x17.de/post/managing-12-machines-with-nixos/</guid>
      <description>How I went from manually babysitting Linux boxes to declaratively managing servers, desktops, a gaming rig, and a VR machine from a single git repo - and why I&amp;#39;ll never go back.</description>
      <content:encoded><![CDATA[<h2 id="the-pain-before-the-solution">The Pain Before the Solution</h2>
<p>I started with Gentoo. If you know, you know: USE flags, <code>emerge --sync</code>, watching a browser compile for six hours, and the quiet satisfaction of a system shaped entirely to your will. It was maximum configuration at maximum cost - every package exactly how you wanted it, every kernel option hand-chosen, every dependency chain personally audited. It was also completely unreproducible. The system on disk was the result of hundreds of commands typed across months. If the disk died, so did the configuration. You could document it, but documentation and reality drift apart fast.</p>
<p><img alt="nixos journey" loading="lazy" src="/post/managing-12-machines-with-nixos/nixos-journey.png"></p>
<p>After a few years of that, I migrated my servers to Debian. Less customization, fewer surprises, faster iteration. Debian is rock solid. But the gap between &ldquo;I&rsquo;ve configured this&rdquo; and &ldquo;this is reproducible&rdquo; never closed. The configuration lived partly in files, partly in database state, partly in my memory. Upgrading a server meant hoping nothing drift-collided with the new packages. I learned to fear dist-upgrades.</p>
<p>Multi-machine management made this worse. When I had three servers to keep consistent, I reached for Ansible. Later tried Salt. Both are genuinely useful tools - they brought the configuration into version control, made state more explicit, handled idempotent runs reasonably well. But they&rsquo;re scaffolding over a fundamentally mutable OS. Ansible describes <em>actions</em> to take, not the <em>state</em> to achieve. The playbook says &ldquo;ensure this package is installed&rdquo; and &ldquo;write this file,&rdquo; but it says nothing about what happens to the thousand other files the system has accumulated over the years. You run the playbook, the system mostly matches what you expect, and you&rsquo;re left with a lingering uncertainty: what else is in there?</p>
<p>Building a WireGuard mesh with Salt illustrates the problem. You write YAML templates, manage state files, think carefully about idempotency edge cases, and you still have hosts where salt-minion mysteriously fell out of sync six months ago and nobody noticed. The tool is fighting the problem. You&rsquo;re layering abstraction on top of a mutable foundation and hoping the accretion doesn&rsquo;t bite you.</p>
<p>NixOS is different in kind, not just in degree. The whole system is declared as a pure function over text files. Run the same config on two machines and you get the same system. Roll back to yesterday&rsquo;s config and you get yesterday&rsquo;s system. The &ldquo;what else is in there&rdquo; question dissolves because the system is a direct consequence of what&rsquo;s in the repo.</p>
<p>That framing clicked for me when I started this config, now covering a dozen machines across servers, desktops, a gaming rig, and a VR setup. This isn&rsquo;t a single desktop experiment. It&rsquo;s a homelab, and the whole thing lives in one git repository.</p>
<hr>
<h2 id="what-nixos-actually-is">What NixOS Actually Is</h2>
<p>The central insight: your entire NixOS system is a pure function. Given the same inputs - configuration files, package hashes, module options - you get the same, bit-for-bit reproducible system. Not &ldquo;mostly the same.&rdquo; The same.</p>
<p>Three consequences fall out of this:</p>
<p><strong>Reproducibility.</strong> If a coworker asks for the same toolchain you use, you share a Nix expression instead of a wiki page. It works the first time.</p>
<p><strong>Rollback.</strong> Every <code>nixos-rebuild switch</code> creates a new system generation. If the new kernel panics at boot, you select the previous generation from the bootloader menu. The old generation is still there on disk, unchanged. You lose nothing.</p>
<p><strong>Fearless experimentation.</strong> Want to try a different init setup, a new compositor, a service you&rsquo;ve never configured before? Add it, switch, try it. If it&rsquo;s wrong, roll back. The cost of experimentation drops to nearly zero.</p>
<p>It&rsquo;s worth being clear about what NixOS is <em>not</em>. It&rsquo;s not just a package manager (though it has one). It&rsquo;s not Docker - there are no containers or images involved in basic NixOS usage, and your system packages live in the Nix store, not a layered filesystem. It&rsquo;s not Ansible - there are no playbooks, no target hosts, no SSH connections during a normal rebuild. The system is <em>built</em> from a description; it doesn&rsquo;t <em>describe operations</em> to run against a running system.</p>
<p>The honest note on the learning curve: Nix the language is genuinely strange. It&rsquo;s lazy and functional, variables are declarations rather than assignments, and error messages can be opaque. The first two weeks feel like reading documentation written for people who already understand it. That phase ends, and what&rsquo;s on the other side is worth it. But it is a real ramp.</p>
<hr>
<h2 id="the-flake-one-entry-point-for-everything">The Flake: One Entry Point for Everything</h2>
<p>A flake is Nix&rsquo;s mechanism for pinned, reproducible builds. A <code>flake.nix</code> declares what your project depends on (inputs) and what it produces (outputs). The <code>flake.lock</code> file pins every input to an exact commit hash and content hash, giving you a complete bill of materials for your infrastructure.</p>
<p>The inputs section of this config shows the pinning strategy clearly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>inputs <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  nixpkgs<span style="color:#f92672">.</span>url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:nixos/nixpkgs?ref=nixos-unstable&#34;</span>;
</span></span><span style="display:flex;"><span>  home-manager <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:nix-community/home-manager&#34;</span>;
</span></span><span style="display:flex;"><span>    inputs<span style="color:#f92672">.</span>nixpkgs<span style="color:#f92672">.</span>follows <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;nixpkgs&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  sops <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:Mic92/sops-nix&#34;</span>;
</span></span><span style="display:flex;"><span>    inputs<span style="color:#f92672">.</span>nixpkgs<span style="color:#f92672">.</span>follows <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;nixpkgs&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  disko <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:nix-community/disko&#34;</span>;
</span></span><span style="display:flex;"><span>    inputs<span style="color:#f92672">.</span>nixpkgs<span style="color:#f92672">.</span>follows <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;nixpkgs&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  hyprland <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:hyprwm/Hyprland/v0.54.2&#34;</span>;
</span></span><span style="display:flex;"><span>    inputs<span style="color:#f92672">.</span>nixpkgs<span style="color:#f92672">.</span>follows <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;nixpkgs&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  hy3 <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:outfoxxed/hy3?ref=hl0.54.2&#34;</span>;
</span></span><span style="display:flex;"><span>    inputs<span style="color:#f92672">.</span>hyprland<span style="color:#f92672">.</span>follows <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;hyprland&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  lanzaboote <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    url <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;github:nix-community/lanzaboote&#34;</span>;
</span></span><span style="display:flex;"><span>    inputs<span style="color:#f92672">.</span>nixpkgs<span style="color:#f92672">.</span>follows <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;nixpkgs&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>Notice two things: <code>inputs.nixpkgs.follows = &quot;nixpkgs&quot;</code> propagates throughout, ensuring all these tools share one nixpkgs version rather than each pulling their own. And Hyprland is pinned to <code>v0.54.2</code> with hy3 at the matching <code>hl0.54.2</code> ref - intentional, because the Hyprland plugin API changes between releases.</p>
<p>For that last point, pinning isn&rsquo;t enough if you need the pinned version available system-wide. The overlay pattern solves it. An overlay in Nix is a function that takes the final and previous package sets and returns overrides. The hyprland overlay replaces the nixpkgs hyprland package with the pinned one:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>hyprlandOverlay <span style="color:#f92672">=</span> final: prev:
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">let</span>
</span></span><span style="display:flex;"><span>    hyprlandPkgs <span style="color:#f92672">=</span> inputs<span style="color:#f92672">.</span>hyprland<span style="color:#f92672">.</span>packages<span style="color:#f92672">.</span><span style="color:#e6db74">${</span>system<span style="color:#e6db74">}</span>;
</span></span><span style="display:flex;"><span>    hy3Pkgs <span style="color:#f92672">=</span> inputs<span style="color:#f92672">.</span>hy3<span style="color:#f92672">.</span>packages<span style="color:#f92672">.</span><span style="color:#e6db74">${</span>system<span style="color:#e6db74">}</span>;
</span></span><span style="display:flex;"><span>    hyprlandPkg <span style="color:#f92672">=</span> hyprlandPkgs<span style="color:#f92672">.</span>hyprland or hyprlandPkgs<span style="color:#f92672">.</span>default;
</span></span><span style="display:flex;"><span>    hy3Pkg <span style="color:#f92672">=</span> hy3Pkgs<span style="color:#f92672">.</span>hy3 or hy3Pkgs<span style="color:#f92672">.</span>default;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">in</span>
</span></span><span style="display:flex;"><span>  {
</span></span><span style="display:flex;"><span>    hyprland <span style="color:#f92672">=</span> hyprlandPkg;
</span></span><span style="display:flex;"><span>    hyprland-unwrapped <span style="color:#f92672">=</span> hyprlandPkgs<span style="color:#f92672">.</span>hyprland-unwrapped or hyprlandPkg;
</span></span><span style="display:flex;"><span>    hyprlandPackages <span style="color:#f92672">=</span> hyprlandPkgs;
</span></span><span style="display:flex;"><span>    hyprlandPlugins <span style="color:#f92672">=</span> (prev<span style="color:#f92672">.</span>hyprlandPlugins or { }) <span style="color:#f92672">//</span> { hy3 <span style="color:#f92672">=</span> hy3Pkg; };
</span></span><span style="display:flex;"><span>    hy3 <span style="color:#f92672">=</span> hy3Pkg;
</span></span><span style="display:flex;"><span>  };
</span></span></code></pre></div><p>This overlay gets applied in <code>nixpkgs.overlays</code>, so every module in the entire config that references <code>pkgs.hyprland</code> gets the pinned version. No special imports, no passing versions around - the whole system agrees on one build.</p>
<p>The most satisfying part of the flake setup is how new hosts appear automatically. There&rsquo;s no host registration, no list to update, no imports to add:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>hostDirs <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>attrNames (lib<span style="color:#f92672">.</span>filterAttrs (_: v: v <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;directory&#34;</span>) (builtins<span style="color:#f92672">.</span>readDir <span style="color:#e6db74">./hosts</span>));
</span></span><span style="display:flex;"><span>mkHosts <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>genAttrs hostDirs;
</span></span></code></pre></div><p><code>builtins.readDir ./hosts</code> returns an attrset mapping names to types (<code>&quot;directory&quot;</code>, <code>&quot;regular&quot;</code>, etc.). Filter to directories, take the names, and generate a NixOS configuration for each one. Every directory inside <code>hosts/</code> automatically becomes a machine. Add a folder, add a <code>default.nix</code> inside it, run <code>nixos-rebuild</code> or deploy - that&rsquo;s it.</p>
<hr>
<h2 id="roles-one-module-system-for-12-different-machines">Roles: One Module System for 12 Different Machines</h2>
<p>Twelve machines sound like twelve configs to maintain. They&rsquo;re not. Machines are variations on a theme, and the theme is expressed as roles.</p>
<p>The role system starts with a typed option in <code>modules/default.nix</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>options<span style="color:#f92672">.</span>ox<span style="color:#f92672">.</span>roles <span style="color:#f92672">=</span> mkOption {
</span></span><span style="display:flex;"><span>  default <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;desktop&#34;</span> ];
</span></span><span style="display:flex;"><span>  type <span style="color:#f92672">=</span> <span style="color:#66d9ef">with</span> lib<span style="color:#f92672">.</span>types; listOf (enum [ <span style="color:#e6db74">&#34;desktop&#34;</span> <span style="color:#e6db74">&#34;laptop&#34;</span> <span style="color:#e6db74">&#34;server&#34;</span> <span style="color:#e6db74">&#34;games&#34;</span> <span style="color:#e6db74">&#34;vr&#34;</span> <span style="color:#e6db74">&#34;nas&#34;</span> ]);
</span></span><span style="display:flex;"><span>  description <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    The machine roles.
</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><span style="display:flex;"><span>options<span style="color:#f92672">.</span>ox<span style="color:#f92672">.</span>role <span style="color:#f92672">=</span>
</span></span><span style="display:flex;"><span>  lib<span style="color:#f92672">.</span>genAttrs [ <span style="color:#e6db74">&#34;desktop&#34;</span> <span style="color:#e6db74">&#34;laptop&#34;</span> <span style="color:#e6db74">&#34;server&#34;</span> <span style="color:#e6db74">&#34;games&#34;</span> <span style="color:#e6db74">&#34;vr&#34;</span> <span style="color:#e6db74">&#34;nas&#34;</span> ] (name:
</span></span><span style="display:flex;"><span>    mkOption {
</span></span><span style="display:flex;"><span>      type <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>types<span style="color:#f92672">.</span>bool;
</span></span><span style="display:flex;"><span>      readOnly <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>      default <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>elem name config<span style="color:#f92672">.</span>ox<span style="color:#f92672">.</span>roles;
</span></span><span style="display:flex;"><span>      description <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Whether this machine has the </span><span style="color:#e6db74">${</span>name<span style="color:#e6db74">}</span><span style="color:#e6db74"> role.&#34;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>  ) <span style="color:#f92672">//</span> {
</span></span><span style="display:flex;"><span>    enduser <span style="color:#f92672">=</span> mkOption {
</span></span><span style="display:flex;"><span>      type <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>types<span style="color:#f92672">.</span>bool;
</span></span><span style="display:flex;"><span>      readOnly <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>      default <span style="color:#f92672">=</span> config<span style="color:#f92672">.</span>ox<span style="color:#f92672">.</span>role<span style="color:#f92672">.</span>desktop <span style="color:#f92672">||</span> config<span style="color:#f92672">.</span>ox<span style="color:#f92672">.</span>role<span style="color:#f92672">.</span>laptop;
</span></span><span style="display:flex;"><span>      description <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Whether this machine has a desktop or laptop role.&#34;</span>;
</span></span><span style="display:flex;"><span>    };
</span></span><span style="display:flex;"><span>  };
</span></span></code></pre></div><p><code>ox.roles</code> is a list you set per-host: <code>[&quot;server&quot;]</code>, <code>[&quot;desktop&quot; &quot;games&quot; &quot;vr&quot;]</code>, whatever the machine is. <code>ox.role</code> derives boolean attributes from that list automatically - <code>role.server</code>, <code>role.desktop</code>, and so on - plus the computed <code>role.enduser</code> which is true for any machine a human sits in front of.</p>
<p>Those booleans then drive defaults for every feature in the system:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>ox <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  auth<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>  boot<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>  samba<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>enduser;
</span></span><span style="display:flex;"><span>  bluetooth<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>laptop;
</span></span><span style="display:flex;"><span>  printing<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>enduser;
</span></span><span style="display:flex;"><span>  gui<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>enduser;
</span></span><span style="display:flex;"><span>  k8s<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault (role<span style="color:#f92672">.</span>desktop <span style="color:#f92672">||</span> role<span style="color:#f92672">.</span>laptop <span style="color:#f92672">||</span> role<span style="color:#f92672">.</span>server);
</span></span><span style="display:flex;"><span>  k8s<span style="color:#f92672">.</span>server<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>server;
</span></span><span style="display:flex;"><span>  ffmpeg<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>enduser;
</span></span><span style="display:flex;"><span>  hyprland<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>enduser;
</span></span><span style="display:flex;"><span>  packages<span style="color:#f92672">.</span>nixEnable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault role<span style="color:#f92672">.</span>enduser;
</span></span><span style="display:flex;"><span>  ssh<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkDefault <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>When a machine is a server, <code>gui.enable</code>, <code>hyprland.enable</code>, <code>bluetooth.enable</code>, <code>printing.enable</code> all default to false. When it&rsquo;s a laptop, bluetooth turns on. When it&rsquo;s an enduser machine, you get Hyprland, Samba mounts, printing, and the Nix development packages. All without writing those conditions in twelve separate host configs. <code>mkDefault</code> means any individual host can still override a specific option - the defaults are overridable, not forced.</p>
<p>Look at the full <code>brunnen</code> host config, a production server:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>ox <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  hostName <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;brunnen&#34;</span>;
</span></span><span style="display:flex;"><span>  boot <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;grub&#34;</span>;
</span></span><span style="display:flex;"><span>    efi<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> <span style="color:#66d9ef">false</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  roles <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;server&#34;</span> ];
</span></span><span style="display:flex;"><span>  networking<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></code></pre></div><p>That&rsquo;s it. Eight lines of meaningful configuration plus boilerplate imports. The role system handles the rest - no GUI, no Bluetooth, no printing, no Hyprland, no gaming tools. Clean.</p>
<p>Now look at <code>itw</code>, the gaming desktop:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>ox <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  hostName <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;itw&#34;</span>;
</span></span><span style="display:flex;"><span>  boot <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    efi<span style="color:#f92672">.</span>dir <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;/boot/EFI&#34;</span>;
</span></span><span style="display:flex;"><span>    cryptPart <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;crypt&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  roles <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;desktop&#34;</span> <span style="color:#e6db74">&#34;games&#34;</span> <span style="color:#e6db74">&#34;vr&#34;</span> ];
</span></span><span style="display:flex;"><span>  monitor<span style="color:#f92672">.</span>with4k <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>  monitor<span style="color:#f92672">.</span>with4kFix <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span><span style="display:flex;"><span>  video <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;nvidia&#34;</span>;
</span></span><span style="display:flex;"><span>  wayland<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><span style="display:flex;"><span>environment<span style="color:#f92672">.</span>etc<span style="color:#f92672">.</span>crypttab <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  text <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    games /dev/disk/by-uuid/0292b609-...  /etc/luks-keys/games.key luks
</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>fileSystems <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;/games&#34;</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    device <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;/dev/mapper/games&#34;</span>;
</span></span><span style="display:flex;"><span>    fsType <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;ext4&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;/mnt/win&#34;</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    device <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;/dev/disk/by-partuuid/3d69d556-...&#34;</span>;
</span></span><span style="display:flex;"><span>    fsType <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;ntfs&#34;</span>;
</span></span><span style="display:flex;"><span>    options <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;defaults&#34;</span> <span style="color:#e6db74">&#34;noauto&#34;</span> ];
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>This is the most complex host in the repo. NVIDIA GPU, 4K display with DPI fixes, encrypted <code>/games</code> partition with a keyfile, dual-boot Windows on an NTFS mount, VR role on top of gaming. And the entire description still fits on a screen. The role system handles enabling Steam, Lutris, Gamescope, and related tools via <code>role.games</code>; the VR tools via <code>role.vr</code>; the Hyprland Wayland compositor via <code>role.enduser</code>. Setting <code>video = &quot;nvidia&quot;</code> injects the correct Wayland environment variables automatically - more on that in a moment.</p>
<p>That&rsquo;s the full range of the fleet: from a minimal server to a maxed-out gaming and VR workstation, with nothing special needed in between.</p>
<hr>
<h2 id="home-manager-your-dotfiles-are-config-too">home-manager: Your Dotfiles Are Config Too</h2>
<p>NixOS manages the system - packages, services, kernel configuration, networking. home-manager extends the same declarative model into the user&rsquo;s home directory. It manages dotfiles, user services, user packages, and shell configuration, all in the same language and the same git repo.</p>
<p>The <code>modules/home.nix</code> bridges the NixOS config and home-manager with one critical pattern: <code>gconfig</code>. When home-manager modules are evaluated, they need access to the top-level NixOS configuration to ask role questions. <code>gconfig</code> is passed as an extra special argument:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>home-manager<span style="color:#f92672">.</span>extraSpecialArgs <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  gconfig <span style="color:#f92672">=</span> config;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">inherit</span> oxpkgs;
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>Now any home-manager module can ask <code>gconfig.ox.role.enduser</code> or <code>gconfig.ox.wayland.enable</code> and get the host-level answer. The <code>home.nix</code> uses this immediately:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>ox<span style="color:#f92672">.</span>home<span style="color:#f92672">.</span>enable <span style="color:#f92672">=</span> mkDefault config<span style="color:#f92672">.</span>ox<span style="color:#f92672">.</span>role<span style="color:#f92672">.</span>enduser;
</span></span></code></pre></div><p>Servers don&rsquo;t get a user home-manager setup at all. Desktops and laptops do.</p>
<p>The Emacs configuration is the best example of what this buys you. In a single declaration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>home <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  packages <span style="color:#f92672">=</span> [ emacs emacsedit ];
</span></span><span style="display:flex;"><span>  file<span style="color:#f92672">.</span><span style="color:#e6db74">&#34;.emacs&#34;</span><span style="color:#f92672">.</span>source <span style="color:#f92672">=</span> <span style="color:#e6db74">./dot_emacs</span>;
</span></span><span style="display:flex;"><span>  activation<span style="color:#f92672">.</span>emacs-config <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>hm<span style="color:#f92672">.</span>dag<span style="color:#f92672">.</span>entryAfter [ <span style="color:#e6db74">&#34;writeBoundary&#34;</span> ] <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    [ -d &#34;$HOME/git&#34; ] || run mkdir -p &#34;$HOME/git&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    [ -d &#34;$HOME/git/emacs-config&#34; ] || run </span><span style="color:#e6db74">${</span>pkgs<span style="color:#f92672">.</span>git<span style="color:#e6db74">}</span><span style="color:#e6db74">/bin/git clone \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      https://github.com/0x17de/emacs-config &#34;$HOME/git/emacs-config&#34;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    [ -f &#34;$HOME/git/emacs-config/custom.el&#34; ] || run touch \
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      &#34;$HOME/git/emacs-config/custom.el&#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>systemd<span style="color:#f92672">.</span>user<span style="color:#f92672">.</span>services<span style="color:#f92672">.</span>emacs-daemon <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  Unit <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    Description <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;Emacs Daemon&#34;</span>;
</span></span><span style="display:flex;"><span>    After <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;graphical-session.target&#34;</span> ];
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  Service <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    Type <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;notify&#34;</span>;
</span></span><span style="display:flex;"><span>    ExecStart <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>pkgs<span style="color:#f92672">.</span>emacs-pgtk<span style="color:#e6db74">}</span><span style="color:#e6db74">/bin/emacs --fg-daemon&#34;</span>;
</span></span><span style="display:flex;"><span>    ExecStop <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;</span><span style="color:#e6db74">${</span>pkgs<span style="color:#f92672">.</span>emacs-pgtk<span style="color:#e6db74">}</span><span style="color:#e6db74">/bin/emacsclient --eval &#39;(kill-emacs)&#39;&#34;</span>;
</span></span><span style="display:flex;"><span>    Restart <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;on-failure&#34;</span>;
</span></span><span style="display:flex;"><span>    RestartSec <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;2s&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>  Install <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>    WantedBy <span style="color:#f92672">=</span> [ <span style="color:#e6db74">&#34;graphical-session.target&#34;</span> ];
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p>Four things at once: installs <code>emacs-pgtk</code> and wrapper scripts (<code>emacs</code> and <code>emacsedit</code>), writes a <code>.emacs</code> bootstrap file, registers a <code>systemd</code> user service that starts the daemon after the graphical session is up, and clones the external Emacs configuration repository on first activation. Fresh machine, run the rebuild, open Emacs - everything is there.</p>
<p>Where to draw the line: Nix manages the install, the service lifecycle, and the initial bootstrap. The Emacs config itself (keybindings, packages, themes) lives in its own git repository and manages itself through <code>package.el</code> or <code>use-package</code>. Nix doesn&rsquo;t try to own what the application is good at managing.</p>
<hr>
<h2 id="the-desktop-stack">The Desktop Stack</h2>
<h3 id="hyprland-and-the-overlay-pattern">Hyprland and the Overlay Pattern</h3>
<p>Hyprland&rsquo;s Wayland compositor is pinned to v0.54.2 across the whole fleet. This is intentional - the plugin API changes with releases, and the hy3 tiling plugin must match. The overlay from Section 3 means every machine that runs Hyprland gets the same version without any per-host specification.</p>
<p>Monitor configuration is generated per-host from <code>ox.hyprland.monitors</code>. The <code>itw</code> host sets <code>monitor.with4k = true</code> and <code>monitor.with4kFix = true</code>, which triggers the correct scaling and DPI configuration in the Hyprland module. When <code>ox.video = &quot;nvidia&quot;</code>, the module automatically injects the Wayland-specific NVIDIA environment variables (<code>GBM_BACKEND</code>, <code>__GLX_VENDOR_LIBRARY_NAME</code>, <code>LIBVA_DRIVER_NAME</code>, and the rest). No hunting through Arch Wiki pages at 2am for the magic env var combination. Declare the GPU, get the right environment.</p>
<h3 id="rebuilding-the-running-system">Rebuilding the Running System</h3>
<p>The most-used command in the workflow is <code>nho</code>, defined in <code>modules/nushell/config/nix.nu</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>def nho --wrapped [...rest] {
</span></span><span style="display:flex;"><span>    cd ~/git/nix
</span></span><span style="display:flex;"><span>    nh os switch . ...$rest
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>nh</code> is a wrapper around <code>nixos-rebuild</code> that adds better output formatting, diff reporting, and some quality-of-life features. This alias changes into the repo directory and invokes <code>nh os switch</code> - rebuild from the current flake, apply immediately. The <code>--wrapped</code> flag in Nushell passes extra arguments through unchanged, so <code>nho --hostname other-machine</code> works for remote deploys.</p>
<p>Make a config change, type <code>nho</code>, the system rebuilds and switches atomically. If something is wrong, the previous generation is one bootloader selection away. That feedback loop changes how you interact with your configuration.</p>
<h3 id="nushell-as-primary-shell">Nushell as Primary Shell</h3>
<p>Nushell runs as the primary interactive shell on all enduser machines. Its typed pipeline model, structured data handling, and built-in table rendering make it particularly good for homelab work. The config includes a suite of plugins: <code>polars</code> for dataframe operations, <code>query</code> for SQL-like filtering, <code>net</code> for network diagnostics, <code>formats</code> for parsing structured file formats. These are declared in the nushell module and activated automatically.</p>
<h3 id="gaming-on-nixos">Gaming on NixOS</h3>
<p>NixOS has a reputation for being difficult for gaming. That reputation is stale.</p>
<p>Steam, Lutris, Heroic Games Launcher, ProtonUp-Qt, MangoHUD, and Gamescope are all declared in the <code>games</code> module, enabled automatically when <code>role.games</code> is true. Steam&rsquo;s FHS compatibility layer handles most Windows titles without any special configuration. Proton versions are managed via ProtonUp-Qt.</p>
<p>The occasional game that absolutely requires an FHS environment - a proprietary launcher that hardcodes library paths, for instance - can be wrapped in a custom <code>buildFHSEnv</code> expression. That&rsquo;s about ten lines of Nix and not a deep rabbit hole. <code>nix-alien</code> was tried and found unreliable; the <code>buildFHSEnv</code> approach is more transparent and predictable.</p>
<p>NVIDIA gaming is handled by the <code>video = &quot;nvidia&quot;</code> flag described above. On <code>itw</code>, Gamescope handles frame pacing and fullscreen correctly for both native Linux titles and Windows games via Proton.</p>
<p>The role system means gaming tools are only present on machines that declare <code>role.games</code>. Servers stay lean. Desktops used for work but not gaming skip the Steam runtime and all the associated tooling. Nothing to manage, nothing to drift.</p>
<hr>
<h2 id="secrets-without-headaches-sops--age">Secrets Without Headaches: SOPS + Age</h2>
<p>The natural objection to &ldquo;everything is in git&rdquo;: what about passwords?</p>
<p>The answer is <a href="https://github.com/mozilla/sops">SOPS</a> with age keys. Secrets are encrypted in the repository - committed plaintext is never stored - and decrypted at activation time by the <code>sops-nix</code> NixOS module. Each secret is mounted as a file with explicit ownership and permissions.</p>
<p>The PowerDNS module shows this concretely. The primary DNS server needs an API key, and that key must be accessible to the service at runtime but must never appear in plaintext in git or the Nix store:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-nix" data-lang="nix"><span style="display:flex;"><span>config <span style="color:#f92672">=</span> mkIf cfg<span style="color:#f92672">.</span>enable {
</span></span><span style="display:flex;"><span>  sops<span style="color:#f92672">.</span>templates<span style="color:#f92672">.</span><span style="color:#e6db74">&#34;pdns-env&#34;</span> <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkIf (<span style="color:#f92672">!</span>cfg<span style="color:#f92672">.</span>secondary) {
</span></span><span style="display:flex;"><span>    content <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">      PDNS_API_KEY=</span><span style="color:#e6db74">${</span>config<span style="color:#f92672">.</span>sops<span style="color:#f92672">.</span>placeholder<span style="color:#f92672">.</span><span style="color:#e6db74">&#34;powerdns/api-key&#34;</span><span style="color:#e6db74">}</span><span style="color:#e6db74">
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    &#39;&#39;</span>;
</span></span><span style="display:flex;"><span>    owner <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;pdns&#34;</span>;
</span></span><span style="display:flex;"><span>    group <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;pdns&#34;</span>;
</span></span><span style="display:flex;"><span>    mode  <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;0400&#34;</span>;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  services<span style="color:#f92672">.</span>powerdns <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>    extraConfig <span style="color:#f92672">=</span> baseExtraConfig;
</span></span><span style="display:flex;"><span>    secretFile <span style="color:#f92672">=</span> lib<span style="color:#f92672">.</span>mkIf (<span style="color:#f92672">!</span>cfg<span style="color:#f92672">.</span>secondary) config<span style="color:#f92672">.</span>sops<span style="color:#f92672">.</span>templates<span style="color:#f92672">.</span><span style="color:#e6db74">&#34;pdns-env&#34;</span><span style="color:#f92672">.</span>path;
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>};
</span></span></code></pre></div><p><code>config.sops.placeholder.&quot;powerdns/api-key&quot;</code> is a reference to an encrypted value in <code>secrets/</code>. During activation, <code>sops-nix</code> decrypts it and writes the rendered template to a path in <code>/run/secrets/</code>, owned by <code>pdns:pdns</code>, mode 0400. <code>services.powerdns.secretFile</code> tells the service to load environment variables from that path. The API key never appears in the Nix store. The secret file in <code>secrets/</code> is committed encrypted with age - anyone with the host&rsquo;s age key (generated at first boot, stored in <code>/etc/ssh/ssh_host_ed25519_key</code>) can decrypt it; everyone else sees ciphertext.</p>
<p>This pattern applies uniformly to database passwords, API keys, WireGuard private keys, and anything else that shouldn&rsquo;t be readable by arbitrary processes. The <code>secrets/</code> directory lives in the same repo as everything else. The repo truly is the single source of truth.</p>
<hr>
<h2 id="deploying-a-new-machine-in-one-command">Deploying a New Machine in One Command</h2>
<p>New machine provisioning follows a single script:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>nix run github:nix-community/nixos-anywhere -- <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --flake .#<span style="color:#e6db74">&#34;</span>$NAME<span style="color:#e6db74">&#34;</span> <span style="color:#e6db74">&#34;</span>$REMOTE<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  --generate-hardware-config nixos-generate-config <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  <span style="color:#e6db74">&#34;./hosts/</span><span style="color:#e6db74">${</span>NAME<span style="color:#e6db74">}</span><span style="color:#e6db74">/hardware-configuration.nix&#34;</span>
</span></span></code></pre></div><p><code>nixos-anywhere</code> SSHs into a running NixOS installer (booted from a USB stick or network image), generates hardware configuration for the specific machine, partitions disks according to the host&rsquo;s <code>disko.nix</code>, and installs the full system from the flake. The only prerequisites are a host config in <code>hosts/$NAME/</code> and SSH access to the target.</p>
<p>Disk layout is declared per-host in <code>disko.nix</code>. LUKS encryption, partition sizes, filesystem types, subvolumes - all expressed as Nix. The <code>itw</code> host has an encrypted system partition, a separate encrypted <code>/games</code> partition with a keyfile, and an NTFS Windows partition, all described in code and applied automatically during provisioning.</p>
<p>After the initial bootstrap, all future updates are <code>nho</code> - atomic, with rollback available from the bootloader. The setup cost is one remote command; the ongoing cost is nearly zero.</p>
<p>The PowerDNS setup illustrates what this means at scale. Two servers - <code>schere</code> (primary) and <code>stein</code> (secondary) - run the DNS infrastructure. Both configs live in the same repo. Adding or changing a DNS-related NixOS option is one file edit, then two deploys: <code>nho --hostname schere</code> and <code>nho --hostname stein</code>. No config management layer to synchronize, no state to reconcile, no &ldquo;did I update both servers?&rdquo; uncertainty.</p>
<hr>
<h2 id="the-hard-parts">The Hard Parts</h2>
<p>In the interest of accuracy: NixOS has rough edges.</p>
<p><strong>The language.</strong> Nix is lazy and functional, with an evaluation model that can surprise you. Error messages have improved significantly in recent Nix versions, but a type mismatch deep in a module evaluation can still produce a backtrace that takes time to interpret. The learning curve is front-loaded; it gets easier, but the first weeks are real.</p>
<p><strong><code>nixos-unstable</code> breakage.</strong> This config tracks <code>nixos-unstable</code>, which means <code>nix flake update</code> occasionally introduces a broken package or module API change. The fix is usually in nixpkgs within a day or two, and rollback handles the immediate problem - but it does happen. The stable channels are calmer.</p>
<p><strong>Proprietary FHS binaries.</strong> Most software works fine. Occasionally a binary with hardcoded library paths needs a <code>buildFHSEnv</code> wrapper. It&rsquo;s ten lines of Nix and not scary once you&rsquo;ve done it once, but it&rsquo;s a known rough edge.</p>
<p><strong>First-time secure boot setup.</strong> Getting lanzaboote (Secure Boot), LUKS encryption, and disko all working correctly on first install has a real setup cost. The pieces fit together well once configured, but the first time through the documentation takes effort.</p>
<p><strong>Honest scope assessment.</strong> If you have one machine and enjoy imperative tinkering, the learning investment may not be worth the return. NixOS pays off at scale, for reproducibility across machines, or for users who want the peace of mind that comes from knowing exactly what&rsquo;s on each system. Single-machine casual users are not the target audience.</p>
<hr>
<h2 id="where-this-leads">Where This Leads</h2>
<p>A year ago, provisioning a new server meant: boot the installer, partition manually, install the base system, copy configs from another machine, run Ansible, debug what drifted, repeat. With this setup, it&rsquo;s <code>./install-remote.sh newhost root@192.168.1.x</code> and wait. The system that comes up is defined entirely by the config in git, with no variation possible.</p>
<p>That mental shift - from &ldquo;the system is the result of what I&rsquo;ve done to it&rdquo; to &ldquo;the system is a function of what I&rsquo;ve declared&rdquo; - is the core of what NixOS offers.</p>
<p>A few threads worth pulling on from here:</p>
<p><strong>disko and LUKS in depth.</strong> The declarative disk setup deserves its own writeup. Combining disko, LUKS, and lanzaboote (Secure Boot) on a single machine, from a declarative base, is a solved problem that&rsquo;s worth documenting step by step.</p>
<p><strong>Writing your own NixOS module.</strong> The <code>ox.*</code> namespace in this repo is a collection of custom modules. The pattern - options, defaults, conditional config - is learnable in an afternoon and immediately useful for your own abstraction layer.</p>
<p><strong>home-manager on non-NixOS.</strong> The home-manager configuration described here works on any Linux (or macOS) system, not just NixOS. If NixOS on bare metal is too much commitment, running home-manager standalone on Debian or Ubuntu gets you the dotfile management and reproducible user environment without changing the OS.</p>
<p>Where to start: <a href="https://nixos.org">nixos.org</a> → <a href="https://nix.dev">nix.dev</a> → find a real flake on GitHub and read it → start with one machine.</p>
<p>The whole system is a function. Write better inputs; get a better system. That&rsquo;s the deal.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
