<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>ZFS | Bas Nijholt</title><link>https://www.nijho.lt/tag/zfs/</link><atom:link href="https://www.nijho.lt/tag/zfs/index.xml" rel="self" type="application/rss+xml"/><description>ZFS</description><generator>Hugo</generator><language>en-us</language><copyright>© 2026</copyright><lastBuildDate>Mon, 28 Sep 2026 00:00:00 +0000</lastBuildDate><image><url>https://www.nijho.lt/media/icon_hu_408a8f6cef43e327.png</url><title>ZFS</title><link>https://www.nijho.lt/tag/zfs/</link></image><item><title>Friend-to-friend ZFS backups, version two</title><link>https://www.nijho.lt/post/zfs-tenant/</link><pubDate>Mon, 28 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/zfs-tenant/</guid><description>&lt;p&gt;Two years ago I wrote about &lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;friend-to-friend backups with TrueNAS&lt;/a&gt;.&#10;A friend and I store each other&amp;rsquo;s ZFS snapshots, which is the cheapest off-site backup there is.&#10;TrueNAS&amp;rsquo;s replication tasks wanted root on the receiving system, and neither of us wanted to give the other root on the machine that holds our family photos.&#10;So each of us ran a TrueNAS VM on a hypervisor, backed by an iSCSI zvol on the real NAS, and gave the other person root inside that VM instead.&#10;Sounds complicated?&#10;It was.&#10;It worked, but it meant running ZFS on a zvol on ZFS, inside a VM that each of us had to keep updated.&lt;/p&gt;&#10;&lt;p&gt;Since then I &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;replaced TrueNAS with NixOS&lt;/a&gt;, and my friend plans to move his machines to NixOS too.&#10;This time we looked for the simplest solution, built from standard Linux and ZFS features wherever possible.&#10;He wanted plain &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt;, with his data still encrypted and his keys never on my server.&#10;I agreed, on one condition: neither of us should be able to destroy the other&amp;rsquo;s datasets.&lt;/p&gt;&#10;&lt;p&gt;The result is &lt;a href="https://github.com/basnijholt/zfs-tenant" target="_blank" rel="noopener"&gt;zfs-tenant&lt;/a&gt;, which is now &lt;a href="https://pypi.org/project/zfs-tenant/" target="_blank" rel="noopener"&gt;on PyPI&lt;/a&gt; and has &lt;a href="https://zfs-tenant.nijho.lt" target="_blank" rel="noopener"&gt;documentation&lt;/a&gt;.&lt;/p&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/zfs-tenant/logo.svg" width="220" alt="The zfs-tenant logo: a storage pool with two dim compartments for the host and one locked amber compartment for a friend, filled up to a dashed quota line" loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#why-zfs"&gt;Why ZFS&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-we-wanted"&gt;What we wanted&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#zfs-already-had-most-of-it"&gt;ZFS already had most of it&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#rehearsing-the-permissions-in-a-vm"&gt;Rehearsing the permissions in a VM&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#a-gate-that-speaks-syncoid"&gt;A gate that speaks syncoid&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#zones-a-second-wall-in-the-kernel"&gt;Zones: a second wall in the kernel&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#who-stops-what"&gt;Who stops what&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-it-cannot-hide"&gt;What it cannot hide&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#setting-it-up"&gt;Setting it up&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#knowing-when-it-stops-working"&gt;Knowing when it stops working&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#restoring"&gt;Restoring&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-first-real-push"&gt;The first real push&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#tested-like-everything-else"&gt;Tested like everything else&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#ai-scope-creep-or-useful"&gt;AI scope creep or useful?&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="why-zfs"&gt;Why ZFS &lt;a class="anchor" href="#why-zfs" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://openzfs.org/" target="_blank" rel="noopener"&gt;OpenZFS&lt;/a&gt; is a filesystem and a volume manager in one.&#10;You add disks to a pool, and it behaves like one large partition: every dataset you create draws from the same free space, and each can still have its own quota and encryption.&#10;ZFS checksums every block it writes, so it notices when a disk hands back something else, and with a mirror or RAIDZ it repairs the block from a good copy.&#10;That protection costs disk space: a mirror of two disks holds one disk&amp;rsquo;s worth of data.&lt;/p&gt;&#10;&lt;p&gt;For me, snapshots are one of the coolest parts.&#10;ZFS never overwrites data in place, so a snapshot only has to hold on to the blocks that existed at that moment.&#10;Taking one is instant, and it costs no space until the data changes.&#10;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-send.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs send&lt;/code&gt;&lt;/a&gt; turns a snapshot, or the difference between two snapshots, into a stream that &lt;code&gt;zfs receive&lt;/code&gt; writes into a pool on another machine, usually piped through SSH.&#10;It moves blocks, not files: an incremental send contains only the blocks that changed, and ZFS knows which ones those are without visiting a single file.&#10;A bajillion one-kilobyte files do not slow it down, because nothing has to look at them one by one.&#10;For comparison, restic on my PC needed &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;an hour and a half per run&lt;/a&gt; just to check close to a hundred million files for changes.&lt;/p&gt;&#10;&lt;p&gt;Two tools from the same project automate this.&#10;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;sanoid&lt;/a&gt; takes snapshots on a schedule and prunes them by policy, for example hourly ones for a day and daily ones for a month.&#10;syncoid wraps &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt; over SSH: it finds the newest snapshot both sides share and sends everything after it.&lt;/p&gt;&#10;&lt;h2 id="what-we-wanted"&gt;What we wanted &lt;a class="anchor" href="#what-we-wanted" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;We wrote down the rules before anything else:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;He cannot see any of my data, including metadata like dataset names and sizes.&lt;/li&gt;&#10;&lt;li&gt;He cannot change or destroy anything of mine.&lt;/li&gt;&#10;&lt;li&gt;I cannot read his data, because his keys stay at his house.&lt;/li&gt;&#10;&lt;li&gt;He gets one dataset where he can create and remove nested datasets as he likes.&lt;/li&gt;&#10;&lt;li&gt;That dataset has a size limit.&lt;/li&gt;&#10;&lt;li&gt;No new replication tool on either side: plain &lt;code&gt;zfs send&lt;/code&gt; or syncoid.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Until then his NAS runs TrueNAS, so whatever I built also had to work on a machine without NixOS.&lt;/p&gt;&#10;&lt;h2 id="zfs-already-had-most-of-it"&gt;ZFS already had most of it &lt;a class="anchor" href="#zfs-already-had-most-of-it" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;OpenZFS has a permission system that most people never touch: &lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-allow.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs allow&lt;/code&gt;&lt;/a&gt;.&#10;It delegates individual operations like &lt;code&gt;receive&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, and &lt;code&gt;destroy&lt;/code&gt; on one dataset to an unprivileged user.&#10;Put a root-owned &lt;code&gt;quota&lt;/code&gt; on that dataset and the user cannot store more than you agreed.&#10;Raw sends (&lt;code&gt;zfs send -w&lt;/code&gt;) ship the encrypted blocks as they are, so the receiving side never needs the key.&lt;/p&gt;&#10;&lt;p&gt;On paper, that covers five of the six rules.&#10;The gap is visibility.&#10;Any local user can run &lt;code&gt;zfs list&lt;/code&gt; and see every dataset on the machine, with names, sizes, and properties.&#10;And an SSH account for replication is, by default, also a shell.&lt;/p&gt;&#10;&lt;p&gt;There are tools that would sidestep some of this.&#10;&lt;a href="https://zrepl.github.io/" target="_blank" rel="noopener"&gt;zrepl&lt;/a&gt; has a sink mode with a subtree per client, but it replaces sanoid and syncoid on both sides and runs as root on the receiver.&#10;restic or borg to a friend&amp;rsquo;s box would also work, but everything else on my network is ZFS: my machines &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/replication.nix#L40-L49" target="_blank" rel="noopener"&gt;replicate to the NAS with syncoid&lt;/a&gt;, and a restore is &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt;.&#10;Adding another backup format means one more thing to understand when something breaks.&#10;My current off-site copy goes to &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/docker-lxc/rclone-b2-backup.nix" target="_blank" rel="noopener"&gt;Backblaze B2 with rclone&lt;/a&gt;.&#10;Ideally that would be ZFS as well, and some hosting services accept &lt;code&gt;zfs send&lt;/code&gt;, but they cost far more than object storage.&#10;A friend with a ZFS box costs nothing.&lt;/p&gt;&#10;&lt;h2 id="rehearsing-the-permissions-in-a-vm"&gt;Rehearsing the permissions in a VM &lt;a class="anchor" href="#rehearsing-the-permissions-in-a-vm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Before writing any code, I had an agent build a throwaway NixOS VM test with a real pool, a delegated user, and a list of probes.&#10;Each probe tried one thing the friend should not be able to do and recorded what ZFS answered.&lt;/p&gt;&#10;&lt;p&gt;The first surprise came from the most obvious setup.&#10;With a plain &lt;code&gt;zfs allow -u joe create,receive,destroy,... tank/friends/joe&lt;/code&gt;, the delegated user could run &lt;code&gt;zfs destroy -r tank/friends/joe&lt;/code&gt; and delete the dataset I had created for him, quota included.&#10;Delegated rights apply to the dataset itself as well as everything below it, unless you say otherwise.&#10;The fix is to split them: on the root itself only &lt;code&gt;create,mount,receive&lt;/code&gt; with &lt;code&gt;zfs allow -l&lt;/code&gt;, and the full set only for descendants with &lt;code&gt;zfs allow -d&lt;/code&gt;.&#10;After that change, destroying, snapshotting, re-delegating, or changing the quota of the root all failed with &lt;code&gt;permission denied&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;A send stream can carry properties too, such as a hostile &lt;code&gt;mountpoint=/etc&lt;/code&gt;.&#10;ZFS applies them with the receiving user&amp;rsquo;s rights, so in the VM that property failed with &lt;code&gt;permission denied&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The probes also confirmed the gap: the delegated user saw my &lt;code&gt;tank/host&lt;/code&gt; dataset in &lt;code&gt;zfs list&lt;/code&gt;, and &lt;code&gt;zfs get used tank/host&lt;/code&gt; returned its size.&lt;/p&gt;&#10;&lt;h2 id="a-gate-that-speaks-syncoid"&gt;A gate that speaks syncoid &lt;a class="anchor" href="#a-gate-that-speaks-syncoid" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The shell part has a standard answer.&#10;An &lt;code&gt;authorized_keys&lt;/code&gt; line with &lt;code&gt;restrict,command=&amp;quot;...&amp;quot;&lt;/code&gt; runs one fixed program for every login, and passes whatever the client asked for in &lt;code&gt;SSH_ORIGINAL_COMMAND&lt;/code&gt;.&#10;&lt;code&gt;zfs-tenant gate&lt;/code&gt; is that program.&#10;It tokenizes the request, accepts only the handful of command shapes a backup needs, checks that every dataset name is inside the friend&amp;rsquo;s dataset, and runs &lt;code&gt;zfs&lt;/code&gt; with an argument list it builds itself.&#10;It never starts a shell, so there is nothing to inject into.&lt;/p&gt;&#10;&lt;p&gt;The hard part was knowing exactly which commands syncoid sends, so an agent read &lt;a href="https://github.com/jimsalterjrs/sanoid/blob/v2.3.0/syncoid" target="_blank" rel="noopener"&gt;syncoid&amp;rsquo;s Perl&lt;/a&gt; and logged every remote command it ran.&#10;Most are the &lt;code&gt;zfs&lt;/code&gt; commands you would expect.&#10;One surprise: before each receive, syncoid runs &lt;code&gt;ps -Ao args=&lt;/code&gt; on the receiving machine, to check that no other transfer is already writing to the same dataset.&#10;That command prints every running process with its full command line.&#10;On my NAS, it would show my friend everything I run, so the gate answers with an empty list.&lt;/p&gt;&#10;&lt;p&gt;Reading syncoid that closely also turned up a remote code execution (RCE) bug in version 2.3.0, outside zfs-tenant itself.&#10;In push mode, syncoid asks the receiving host for a ZFS resume token.&#10;It passed that answer unescaped into shell commands for estimating and resuming the send, which run on the source machine.&#10;A malicious or compromised backup host could therefore return shell syntax instead of a token and execute arbitrary commands on the sender with the privileges of the syncoid process.&lt;/p&gt;&#10;&lt;p&gt;I reproduced this in two isolated NixOS VMs using real ZFS and SSH: a harmless command embedded in the receiver&amp;rsquo;s answer created a marker file on the sender.&#10;I reported it to the maintainer and opened &lt;a href="https://github.com/jimsalterjrs/sanoid/pull/1114" target="_blank" rel="noopener"&gt;sanoid PR #1114&lt;/a&gt; with a small fix that shell-quotes the token at both uses.&#10;Even with that fix, the sending side should run syncoid as an unprivileged user that may only &lt;code&gt;send&lt;/code&gt; and &lt;code&gt;hold&lt;/code&gt;.&#10;nixpkgs&amp;rsquo; &lt;code&gt;services.syncoid&lt;/code&gt; already works that way, so my friend needs nothing from zfs-tenant at all.&lt;/p&gt;&#10;&lt;h2 id="zones-a-second-wall-in-the-kernel"&gt;Zones: a second wall in the kernel &lt;a class="anchor" href="#zones-a-second-wall-in-the-kernel" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;At that point the gate was the only thing hiding my datasets.&#10;Then my friend sent me a message: ZFS has a feature called zones, which restricts a dataset tree to a Linux user namespace.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-zone.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs zone&lt;/code&gt;&lt;/a&gt; attaches a dataset to one user namespace, and inside that namespace the ZFS kernel module answers &lt;code&gt;dataset does not exist&lt;/code&gt; for everything that is not attached.&#10;Even if my gate had a bug that let arbitrary &lt;code&gt;zfs&lt;/code&gt; commands through, &lt;code&gt;tank/host&lt;/code&gt; would stay invisible.&#10;The zone does not replace the gate, though: it only filters ZFS, and it only applies once the gate has put the SSH session inside it.&#10;&lt;a href="#who-stops-what"&gt;Who stops what&lt;/a&gt; shows how the two divide the work.&lt;/p&gt;&#10;&lt;p&gt;A user namespace can give its processes different user IDs than they have outside.&#10;Containers usually use that to make the user root inside the namespace, while it stays an ordinary user outside.&#10;ZFS treats root inside a zone as the zone&amp;rsquo;s administrator, allowed to do anything to the attached datasets, whatever &lt;code&gt;zfs allow&lt;/code&gt; says.&#10;In the VM test, that let the friend destroy the dataset I had created for him.&#10;So zfs-tenant keeps him as his own ordinary user inside the namespace: he has no special rights there, &lt;code&gt;zfs allow&lt;/code&gt; decides what he may do, and the kernel still hides everything else.&lt;/p&gt;&#10;&lt;p&gt;Root inside a user namespace also turned up in a real OpenZFS bug in August: before 2.4.4, 2.3.9, and 2.2.11, several pool operations, such as destroying a pool, accepted it as host root (&lt;a href="https://github.com/openzfs/zfs/security/advisories/GHSA-mhf5-q8gw-qg9v" target="_blank" rel="noopener"&gt;CVE-2026-79619&lt;/a&gt;).&#10;Joe cannot reach that through the gate, but zfs-tenant needs unprivileged user namespaces, so a host running it should load a patched module: &lt;code&gt;cat /sys/module/zfs/version&lt;/code&gt; shows the loaded one, which only changes after a reboot.&lt;/p&gt;&#10;&lt;p&gt;A zone needs a running namespace to attach to, so zfs-tenant runs a small service per friend that holds one.&#10;The gate joins that namespace before it looks at the command, drops the capabilities that joining grants, and refuses to run if the service is down.&lt;/p&gt;&#10;&lt;h2 id="who-stops-what"&gt;Who stops what &lt;a class="anchor" href="#who-stops-what" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;When Joe and I talked it through, I found it hard to say clearly which part protects against what, so I made this diagram.&lt;/p&gt;&#10;&lt;figure&gt;&lt;img src="https://www.nijho.lt/post/zfs-tenant/layers.svg" alt="Diagram of seven things Joe&amp;#39;s key tries and the layer that stops each one: the network, the SSH gate, the zone, and zfs allow with the quota. Only his raw encrypted backup reaches my pool, where it stays unreadable to me." loading="lazy" data-zoomable&gt;&lt;/figure&gt;&#10;&lt;p&gt;Each column answers one question: who can connect, what he can run, what ZFS shows him, and what he can change.&#10;For the network, our tailnet only lets his router reach port 22 on my NAS, and &lt;code&gt;from=&lt;/code&gt; in &lt;code&gt;authorized_keys&lt;/code&gt; only accepts his key from that address.&#10;Raw sends then keep whatever reaches my pool unreadable to me.&lt;/p&gt;&#10;&lt;p&gt;The gate and the zone overlap in one job: hiding my other datasets.&#10;The gate does it first, by refusing any name outside his root before &lt;code&gt;zfs&lt;/code&gt; runs.&#10;The zone repeats that check in the kernel, so hiding my datasets would take a bug in both.&#10;Everything else the gate does, the zone cannot:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Without the gate, his key would be a normal account on my NAS. The zone only filters ZFS; it would not stop him from reading &lt;code&gt;/etc&lt;/code&gt; or running programs.&lt;/li&gt;&#10;&lt;li&gt;An SSH login does not start inside a user namespace. The gate joins the zone before it runs anything, so without the gate the zone would never apply.&lt;/li&gt;&#10;&lt;li&gt;The zone leaves some things visible: &lt;code&gt;zpool status&lt;/code&gt; still shows my disks and how full the pool is, and the datasets above his root still show their sizes. User namespaces do not hide processes either, hence the empty answer to &lt;code&gt;ps&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;It deletes any unencrypted dataset he pushes by mistake, so I never keep his plaintext.&lt;/li&gt;&#10;&lt;li&gt;It logs every command, which is how I saw Joe try &lt;code&gt;bash&lt;/code&gt; below.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The &lt;a href="https://zfs-tenant.nijho.lt/security/" target="_blank" rel="noopener"&gt;security model&lt;/a&gt; in the documentation lists every promise with the mechanism behind it.&lt;/p&gt;&#10;&lt;h2 id="what-it-cannot-hide"&gt;What it cannot hide &lt;a class="anchor" href="#what-it-cannot-hide" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;ZFS encryption protects file contents, not structure.&#10;In the words of &lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-load-key.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-load-key(8)&lt;/code&gt;&lt;/a&gt;, ZFS &amp;ldquo;will not encrypt metadata related to the pool structure, including dataset and snapshot names, dataset hierarchy, properties, file size, file holes, and deduplication tables.&amp;rdquo;&#10;So I can see the names, sizes, and snapshot times of whatever my friend sends me.&#10;Boring dataset names help, and sending without &lt;code&gt;-p&lt;/code&gt; keeps properties out of the stream.&lt;/p&gt;&#10;&lt;p&gt;I can also always delete his copy, because I am root on my own machine.&#10;What I can never do is read it.&#10;My &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/storage.nix#L71-L81" target="_blank" rel="noopener"&gt;monthly scrubs&lt;/a&gt; still verify his data without his key, because ZFS checksums the encrypted blocks.&lt;/p&gt;&#10;&lt;p&gt;What we protect against is a dead machine: a failed pool, a fire, a flood.&#10;His key may destroy anything below his root, which is what lets syncoid mirror his snapshot retention, so someone who steals that key can also delete his backups on my NAS.&#10;Covering that would take holds that I place as root and release on a schedule.&#10;We left them out on purpose, although I am &lt;a href="#ai-scope-creep-or-useful"&gt;reconsidering&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="setting-it-up"&gt;Setting it up &lt;a class="anchor" href="#setting-it-up" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;On a NixOS host, giving a friend space takes this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zfs-tenant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;tenants&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;joe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tank/friends/joe&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;quota&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2T&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;authorizedKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... joe-nas&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;allowedFrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;100.64.0.12&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;# his tailnet address&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That creates the user, pins the key to the gate, applies the dataset, properties, and delegation on every boot, and runs the zone service.&#10;My own setup is in &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L43-L50" target="_blank" rel="noopener"&gt;&lt;code&gt;friend-backups.nix&lt;/code&gt;&lt;/a&gt;, with his real key and address.&#10;If your sanoid snapshots the whole pool, &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L52-L59" target="_blank" rel="noopener"&gt;exclude the tenant tree&lt;/a&gt;: your sanoid must neither snapshot nor prune it, or his next incremental push finds snapshots on the target that he never sent.&#10;Once my friend is on NixOS, he can push with nixpkgs&amp;rsquo; own module:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;programs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;knownHosts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bas-nas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publicKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;syncoid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sshKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/var/lib/syncoid/id_ed25519&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;localSourceAllow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;send&amp;#34;&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;hold&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;commonArgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--no-sync-snap&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--compress=none&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--delete-target-snapshots&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;--sshoption=StrictHostKeyChecking=yes&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;commands&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;tank/offsite&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;zfs-tenant-joe@bas-nas:tank/friends/joe/offsite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;recursive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;sendOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;w&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;};&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My NAS will push to his with the &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix#L66-L91" target="_blank" rel="noopener"&gt;same configuration&lt;/a&gt; once he hosts a root for me.&lt;/p&gt;&#10;&lt;p&gt;For his TrueNAS box, every &lt;a href="https://github.com/basnijholt/zfs-tenant/releases" target="_blank" rel="noopener"&gt;release&lt;/a&gt; has a single &lt;code&gt;zfs-tenant.pyz&lt;/code&gt; that runs with the Python TrueNAS already ships, and the &lt;a href="https://zfs-tenant.nijho.lt/getting-started/" target="_blank" rel="noopener"&gt;getting started guide&lt;/a&gt; covers both setups.&#10;Hosting on TrueNAS has only run in VMs so far (&lt;a href="https://github.com/basnijholt/zfs-tenant/issues/4" target="_blank" rel="noopener"&gt;issue #4&lt;/a&gt;).&#10;Sending from TrueNAS is where its built-in tools stop working: replication tasks wrap every remote command in &lt;code&gt;sh -c&lt;/code&gt;, which the gate refuses, so he pushes with &lt;code&gt;zfs send&lt;/code&gt; or syncoid instead.&lt;/p&gt;&#10;&lt;h2 id="knowing-when-it-stops-working"&gt;Knowing when it stops working &lt;a class="anchor" href="#knowing-when-it-stops-working" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The &lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;last btrfs machine&lt;/a&gt; taught me a fifth question for any backup: how do I find out when it stops working?&#10;A failed push shows up in &lt;code&gt;systemctl status&lt;/code&gt; on the sending side, which nobody reads.&#10;The check belongs on the receiving side, and it has to look at every pushed dataset separately, because one healthy dataset can hide another that stopped replicating.&#10;That is how I watch my own machines: the NAS &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/replication.nix#L332-L352" target="_blank" rel="noopener"&gt;checks the newest snapshot of every dataset it replicates&lt;/a&gt; every hour and alerts my phone when one is too old.&#10;zfs-tenant does not alert yet; &lt;a href="https://github.com/basnijholt/zfs-tenant/issues/3" target="_blank" rel="noopener"&gt;issue #3&lt;/a&gt; tracks it.&lt;/p&gt;&#10;&lt;h2 id="restoring"&gt;Restoring &lt;a class="anchor" href="#restoring" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The gate also allows &lt;code&gt;zfs send&lt;/code&gt; of his own snapshots, so getting data back is one pipe, run at his house:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh zfs-tenant-joe@bas-nas zfs send -w tank/friends/joe/offsite/photos@autosnap_2026-09-25_00:00:01_daily &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; zfs receive -u tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zfs load-key tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;zfs mount tank/photos&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My photos are a child dataset that inherits its key from an encryption root, and I only push the child.&#10;If my NAS dies and I pull the child back, can I still unlock it without the root?&#10;A throwaway VM says yes: a raw send carries the dataset&amp;rsquo;s own wrapped key, so the child arrives as its own encryption root and opens with the same passphrase.&#10;The one catch: after a &lt;code&gt;zfs change-key&lt;/code&gt;, the next push carries the new wrapping, so the passphrase that matters is the one at the time of the last push, and it has to live somewhere other than the NAS.&#10;Mine does: &lt;a href="https://github.com/basnijholt/zfs-unlock" target="_blank" rel="noopener"&gt;zfs-unlock&lt;/a&gt; keeps the passphrases on a separate device and &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/zfs-unlock.nix" target="_blank" rel="noopener"&gt;unlocks the NAS&lt;/a&gt; after every boot.&lt;/p&gt;&#10;&lt;h2 id="the-first-real-push"&gt;The first real push &lt;a class="anchor" href="#the-first-real-push" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Once &lt;a href="https://github.com/basnijholt/dotfiles/blob/6526b50e9bae1449a915239491099ef71528f10c/configs/nixos/hosts/nas/friend-backups.nix" target="_blank" rel="noopener"&gt;my side was deployed&lt;/a&gt;, Joe tried it from his TrueNAS box.&#10;His first move was to see what else his key could do.&#10;The gate logged every attempt on my NAS:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied interactive session&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;ls&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;/usr/bin/bash&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe denied &amp;#39;/bin/bash&amp;#39;: only zfs commands and syncoid&amp;#39;s probes are allowed&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;root=tank/friends/joe allowed &amp;#39;zfs receive -s -u tank/friends/joe/zt-test&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The last line is a small encrypted test dataset, sent with &lt;code&gt;zfs send -w&lt;/code&gt; piped into &lt;code&gt;ssh&lt;/code&gt;, without syncoid.&#10;It arrived with &lt;code&gt;keystatus&lt;/code&gt; set to &lt;code&gt;unavailable&lt;/code&gt;.&#10;Then I tried to read it as root on my own machine.&#10;&lt;code&gt;zfs mount&lt;/code&gt; answered &lt;code&gt;cannot mount 'tank/friends/joe/zt-test': dataset is exported to a local zone&lt;/code&gt;, and &lt;code&gt;zfs load-key&lt;/code&gt; with a guessed passphrase gave &lt;code&gt;Key load error: Incorrect key provided&lt;/code&gt;.&#10;The only readable text in the raw stream was ZFS property names and the snapshot name, which is the metadata ZFS leaves unencrypted, as described above.&lt;/p&gt;&#10;&lt;h2 id="tested-like-everything-else"&gt;Tested like everything else &lt;a class="anchor" href="#tested-like-everything-else" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Every security claim in this post is also checked by a VM test: two NixOS machines back each other up, the way my friend and I do, with real sanoid and syncoid 2.3.0, through real sshd and the gate, into real OpenZFS 2.4.4.&#10;It also checks what happens over time, such as snapshot retention and both machines rebooting, and it runs on every push in GitHub Actions.&lt;/p&gt;&#10;&lt;p&gt;The route to the &lt;a href="https://github.com/jimsalterjrs/sanoid/pull/1114" target="_blank" rel="noopener"&gt;remote code execution bug in syncoid&lt;/a&gt; was slightly absurd.&#10;I built the initial version of zfs-tenant through coding agents, literally from the bathtub, and then asked several frontier models to review the security boundary independently.&#10;One of them followed receiver-controlled data beyond my new code and into syncoid.&#10;A model&amp;rsquo;s suspicion is not evidence, so I turned it into the two-VM reproduction above: it failed on unpatched syncoid 2.3.0 and passed after the seven-line fix.&#10;The most consequential result of reviewing my small new project was a command-injection bug in the mature tool next to it.&lt;/p&gt;&#10;&lt;p&gt;The whole thing is about 930 lines of Python, not counting comments and docstrings, with no dependencies.&#10;The kernel does the actual enforcing: delegation, the quota, and the zone.&#10;The gate only removes the shell, and it is small enough to read in one sitting.&lt;/p&gt;&#10;&lt;h2 id="ai-scope-creep-or-useful"&gt;AI scope creep or useful? &lt;a class="anchor" href="#ai-scope-creep-or-useful" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The first version had &lt;em&gt;grace holds&lt;/em&gt;: every day, the host placed a hold on the newest snapshot of each of my friend&amp;rsquo;s datasets and released it 14 days later.&#10;ZFS will not destroy a held snapshot until the hold is released, and only the host can release it.&#10;The agent built it without me asking for it, so I removed it right away as scope creep.&lt;/p&gt;&#10;&lt;p&gt;Then every model I asked to review the project suggested them again, each in its own context window: Opus 5.5, GPT-6 Astra, and even the one that reviewed a draft of this post.&#10;Joe and I had set out to protect against a machine dying (a fire or a flood), not against one of our own machines being hacked.&#10;But I am tempted to add the holds back.&#10;They cover the case where malware or an attacker gets onto my machine and uses my key to delete my backups on his NAS, or to replace them with garbage.&#10;Adding the feature without asking was still wrong; the feature itself might not be.&lt;/p&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/zfs-tenant" target="_blank" rel="noopener"&gt;zfs-tenant on GitHub&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://zfs-tenant.nijho.lt" target="_blank" rel="noopener"&gt;zfs-tenant documentation&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-remote-backups/"&gt;Friend-to-friend TrueNAS backups without the cloud&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;Migrating from TrueNAS to NixOS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/btrfs-to-zfs/"&gt;The last btrfs machine: migrating my PC to ZFS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-allow.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-allow(8)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://openzfs.github.io/openzfs-docs/man/master/8/zfs-zone.8.html" target="_blank" rel="noopener"&gt;&lt;code&gt;zfs-zone(8)&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;Sanoid and syncoid&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>zfs-tenant</title><link>https://www.nijho.lt/project/zfs-tenant/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/project/zfs-tenant/</guid><description/></item><item><title>The last btrfs machine: migrating my PC to ZFS</title><link>https://www.nijho.lt/post/btrfs-to-zfs/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/btrfs-to-zfs/</guid><description>&lt;p&gt;My GPU machine was the last machine in my fleet still running btrfs.&#10;I &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;bought it for gaming&lt;/a&gt; (which never happened), and these days its dual RTX 3090s almost exclusively run &lt;a href="https://www.nijho.lt/post/llama-nixos/"&gt;local AI models&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;btrfs itself never failed me.&#10;When I set up NixOS on this machine for the first time, I had read enough forum threads to become convinced that ZFS and NixOS were a troublesome combination, so I picked btrfs to be safe.&#10;That belief was simply wrong.&#10;As I &lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;went full Nix&lt;/a&gt; and eventually &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;moved even my TrueNAS to NixOS&lt;/a&gt;, I learned there was no issue at all.&#10;I just had to figure out the right incantation to make ZFS work with boot drives, &lt;a href="https://github.com/nix-community/disko" target="_blank" rel="noopener"&gt;disko&lt;/a&gt;, and &lt;a href="https://github.com/nix-community/nixos-anywhere" target="_blank" rel="noopener"&gt;nixos-anywhere&lt;/a&gt;.&#10;My HP got ZFS first, that config grew into a shared disko module, and soon the whole fleet ran one pattern: NixOS on ZFS, snapshots and replication declared in code, and backups I can reason about.&lt;/p&gt;&#10;&lt;p&gt;Only the machine I sit at every day kept running on my old misconception, because a daily driver is exactly the one you do not casually reinstall.&lt;/p&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#why-touch-a-working-machine"&gt;Why touch a working machine&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-backup-that-wasnt"&gt;The backup that wasn&amp;rsquo;t&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#what-the-audit-found"&gt;What the audit found&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-plan-one-shared-disko-module"&gt;The plan: one shared disko module&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#rehearsing-in-a-vm-again"&gt;Rehearsing in a VM, again&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-recovery-kit"&gt;The recovery kit&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#trusting-the-backup-enough-to-wipe-the-disk"&gt;Trusting the backup enough to wipe the disk&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-fifth-question"&gt;The fifth question&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#restoring-in-stages"&gt;Restoring in stages&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#slimming-down-first"&gt;Slimming down first&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-cutover"&gt;The cutover&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="why-touch-a-working-machine"&gt;Why touch a working machine &lt;a class="anchor" href="#why-touch-a-working-machine" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The operating system was already uniform.&#10;The filesystem was not, and that turned out to matter more than I expected.&lt;/p&gt;&#10;&lt;p&gt;The real trigger was that I got increasingly paranoid about an AI agent messing up my system.&#10;I run agentic AI in YOLO mode, exclusively.&#10;All day, every day, agents execute commands on this machine without asking me first.&#10;YOLO does not mean root, though: the agents run as my regular user, and the backup credentials live where only root can read them.&#10;An agent that could destroy the backup itself would make everything else in this post pointless.&#10;Meanwhile the stories keep coming in about even the most frontier models wiping someone&amp;rsquo;s home directory:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;GPT-5.6-Sol just accidentally deleted almost ALL of my Mac&amp;rsquo;s files. And this is why I trust Fable 1000x more.&lt;/p&gt;&#10;&lt;p&gt;— &lt;a href="https://x.com/mattshumer_/status/2075657271401390161" target="_blank" rel="noopener"&gt;@mattshumer_&lt;/a&gt;&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The reaction is always about which model deserves more trust.&#10;I read these tweets differently.&#10;Give any agent enough sessions and it will eventually delete something important, the same way I do every few years with a tired &lt;code&gt;rm -rf&lt;/code&gt;.&#10;Whether that moment becomes a catastrophe or an inconvenience depends entirely on the restore path.&lt;/p&gt;&#10;&lt;p&gt;So I felt I needed to properly audit the way my backups were done.&#10;I want to be able to answer four questions about any machine I own:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;What exactly is in the backup?&lt;/li&gt;&#10;&lt;li&gt;What is &lt;em&gt;not&lt;/em&gt; in the backup, and did I choose that deliberately?&lt;/li&gt;&#10;&lt;li&gt;How big is a restore?&lt;/li&gt;&#10;&lt;li&gt;How long will it take?&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="the-backup-that-wasnt"&gt;The backup that wasn&amp;rsquo;t &lt;a class="anchor" href="#the-backup-that-wasnt" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Asking &lt;span class="hover-tip" data-tooltip="What exactly is in the backup?" tabindex="0"&gt;question 1&lt;/span&gt; delivered an unpleasant answer within the hour: the backups had quietly stopped four months earlier.&#10;Two boring causes had stacked up: an interrupted prune left a stale repository lock, which the job could never clear because its own unlock step only ran &lt;em&gt;after&lt;/em&gt; a successful backup, and the repository URL still pointed at &lt;code&gt;truenas.local&lt;/code&gt;, a name that had stopped resolving &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;along the way&lt;/a&gt;.&#10;The last successful backup ran at 07:00 on March 22; the next one at 07:16 on July 22, after &lt;a href="https://github.com/basnijholt/dotfiles/commit/4d2a75efcb503b9ca2aabcbe8339be3e5a62d0ce" target="_blank" rel="noopener"&gt;the fix&lt;/a&gt;.&#10;Four months, almost to the minute.&#10;systemd logged the failure on every single run, nobody read it, and nothing escalated.&#10;I found out because I asked the question, not because anything told me.&lt;/p&gt;&#10;&lt;h2 id="what-the-audit-found"&gt;What the audit found &lt;a class="anchor" href="#what-the-audit-found" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;On the btrfs box, &lt;span class="hover-tip" data-tooltip="How big is a restore?" tabindex="0"&gt;question 3&lt;/span&gt; alone had three different answers depending on which tool I asked.&#10;&lt;code&gt;du&lt;/code&gt; counted reflinked copies at full size, so directories reported terabytes that physically did not exist.&#10;&lt;code&gt;df&lt;/code&gt; counted physical extents, which is honest about disk usage but tells you nothing about what a file-based restore will write.&#10;And restic reported the &lt;em&gt;apparent&lt;/em&gt; size of sparse VM images, which inflated the restore estimate to several times the disk.&lt;/p&gt;&#10;&lt;p&gt;When your storage layer gives you three sizes for the same data, you do not really understand your backup.&#10;You have a backup, and you have hope.&lt;/p&gt;&#10;&lt;p&gt;&lt;span class="hover-tip" data-tooltip="How long will it take?" tabindex="0"&gt;Question 4&lt;/span&gt; was no better.&#10;Because the PC was my only btrfs machine, it could not join &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/replication.nix" target="_blank" rel="noopener"&gt;the ZFS snapshot replication the rest of the fleet uses&lt;/a&gt;, so its backups relied entirely on restic pushing to the NAS.&#10;Off-machine, note, but not off-site; &lt;span class="hover-tip" data-tooltip="What is not in the backup, and did I choose that deliberately?" tabindex="0"&gt;question 2&lt;/span&gt; turned out to apply to locations too, and closing that gap is its own project.&#10;restic is file-based: every run visits the metadata of every single file to find what changed.&#10;On this machine that meant close to a hundred million files per scan, which turned out to include some 600 GB of git worktrees stuffed with virtual environments and &lt;code&gt;node_modules&lt;/code&gt;.&#10;Each run took about an hour and a half, even when almost nothing had changed.&lt;/p&gt;&#10;&lt;p&gt;That scan time dictated my backup cadence.&#10;The timer ran hourly, which in practice meant back-to-back scanning all day to move a couple hundred megabytes of real changes, and I recently &lt;a href="https://github.com/basnijholt/dotfiles/commit/4f2401de725d5201d4daca0e67505ad2e6f6691a" target="_blank" rel="noopener"&gt;dropped it to every six hours&lt;/a&gt; to stop the madness.&#10;Either way, my most recent work existed only on the machine an agent was busy poking at.&lt;/p&gt;&#10;&lt;p&gt;The scanning is also not free for the hardware.&#10;Reads are gentle on an SSD compared to writes, but hourly scans of an hour and a half each kept the drive at full duty cycle and elevated temperature for most of every day.&#10;And drives stopped being cheap: I paid $227 for &lt;a href="https://www.nijho.lt/post/homelab/"&gt;a 4 TB NVMe drive&lt;/a&gt; in 2024, and the same drive lists for around $800 today.&#10;I would rather not run the experiment of what years of continuous scanning do to a drive I can no longer afford to replace.&lt;/p&gt;&#10;&lt;p&gt;btrfs has perfectly good snapshots of its own, but sending them off-site was never an option for me: snapshot replication needs the same filesystem on the receiving end, and my NAS speaks ZFS.&#10;With ZFS I can snapshot every few minutes and &lt;code&gt;zfs send&lt;/code&gt; the actual block storage: an incremental send streams exactly the blocks that changed since the last snapshot, without walking a single file.&#10;A hundred million files cost nothing when nobody has to visit them.&lt;/p&gt;&#10;&lt;p&gt;ZFS does not magically collapse those numbers into one.&#10;But it gives me the same semantics everywhere, including on the NAS pools that hold the backups themselves.&#10;Eight of my machines already share a single ZFS disk layout, defined in &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/common/disko-zfs.nix" target="_blank" rel="noopener"&gt;one shared disko module&lt;/a&gt;.&#10;The PC was the odd one out.&#10;This migration makes it nine machines with the exact same strategy, and one backup story to audit instead of one per machine.&lt;/p&gt;&#10;&lt;h2 id="the-plan-one-shared-disko-module"&gt;The plan: one shared disko module &lt;a class="anchor" href="#the-plan-one-shared-disko-module" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The Nix side was the easy part.&#10;The PC now imports the same shared disko module as the other eight machines, with one addition: a &lt;code&gt;swapSize&lt;/code&gt; parameter, because ZFS has no reliable swapfile story and the PC actually needs its 96 GB of swap.&lt;/p&gt;&#10;&lt;p&gt;The disk is &lt;a href="https://github.com/basnijholt/dotfiles/blob/8c0bc5faded40362ac01347682aaf2089562047d/configs/nixos/hosts/pc/disko.nix" target="_blank" rel="noopener"&gt;pinned by ID&lt;/a&gt;, not by device name:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/dev/disk/by-id/nvme-Samsung_SSD_990_EVO_Plus_4TB_...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This matters more than usual here.&#10;The PC dual-boots Windows from a second NVMe drive, and I want zero ambiguity about which of the two disks disko is allowed to erase.&lt;/p&gt;&#10;&lt;p&gt;The scary property of this migration is different from the NAS one.&#10;For the NAS, the whole point was that disko must &lt;em&gt;not&lt;/em&gt; touch the data pools.&#10;For the PC, the new ZFS pool goes on the &lt;strong&gt;same disk the data currently lives on&lt;/strong&gt;.&#10;No second disk, no local rollback, no btrfs to boot back into.&#10;From the moment disko runs, the restic repository on the NAS is the only road back.&lt;/p&gt;&#10;&lt;p&gt;That sentence is the reason most of this post is about verification.&lt;/p&gt;&#10;&lt;p&gt;The road back also has to survive the same agents that motivated all of this.&#10;The PC pushes its backups over sftp as a user that owns every file in the repository, which means a compromised PC could delete or encrypt its own safety net.&#10;So the NAS &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/storage.nix#L128-L143" target="_blank" rel="noopener"&gt;snapshots the repository dataset on its own side&lt;/a&gt;, where the PC&amp;rsquo;s credentials cannot reach: two days of hourly ZFS snapshots and two weeks of dailies that only root on the NAS can destroy.&lt;/p&gt;&#10;&lt;h2 id="rehearsing-in-a-vm-again"&gt;Rehearsing in a VM, again &lt;a class="anchor" href="#rehearsing-in-a-vm-again" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The &lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;NAS migration&lt;/a&gt; taught me to rehearse the destructive step in a VM, so the flake grew a &lt;a href="https://github.com/basnijholt/dotfiles/blob/8c0bc5faded40362ac01347682aaf2089562047d/configs/nixos/flake.nix#L110" target="_blank" rel="noopener"&gt;&lt;code&gt;pc-vmtest&lt;/code&gt; target&lt;/a&gt; and I ran the whole install through &lt;code&gt;nixos-anywhere --vm-test&lt;/code&gt;.&#10;Disko formatted a virtual disk, created the pool, installed the system, and booted it with swap active.&lt;/p&gt;&#10;&lt;p&gt;Passing that test proves the generated logic.&#10;It does not prove the backup, which is where the real risk lives.&lt;/p&gt;&#10;&lt;h2 id="the-recovery-kit"&gt;The recovery kit &lt;a class="anchor" href="#the-recovery-kit" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;A restore that starts with &amp;ldquo;first, log in to the dead machine and fetch the credentials&amp;rdquo; is not a restore plan.&#10;So everything a bare reinstall needs lives in a small recovery kit on two other machines in the house, permissions locked down: the restic password and SSH key that reach the repository, the staged restore script, a couple of verification scripts, and the PC&amp;rsquo;s SSH host keys.&#10;The host keys matter more than they look: the reinstalled machine comes back with its old identity, so no client complains and nothing in the fleet needs to be taught to trust a new machine.&#10;A munge key and a README round it out.&lt;/p&gt;&#10;&lt;h2 id="trusting-the-backup-enough-to-wipe-the-disk"&gt;Trusting the backup enough to wipe the disk &lt;a class="anchor" href="#trusting-the-backup-enough-to-wipe-the-disk" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My recovery story has two layers.&#10;NixOS makes the &lt;em&gt;system&lt;/em&gt; disposable: every service, driver fix, and firewall rule is in git, so no agent can leave the machine in a state I cannot rebuild.&#10;Verified backups make the &lt;em&gt;data&lt;/em&gt; recoverable.&#10;This migration is the voluntary version of the tweets above: I am about to delete all of my PC&amp;rsquo;s files on purpose.&lt;/p&gt;&#10;&lt;p&gt;I did three verifications, each one converting an assumption into a fact.&#10;An AI agent did the tedious sweeps; I reviewed the evidence.&lt;/p&gt;&#10;&lt;p&gt;The first check was completeness.&#10;We walked everything on the machine &lt;em&gt;outside&lt;/em&gt; the backup paths, until every byte was accounted for.&#10;It was all re-downloadable caches, and the largest unexplained thing turned out to be restic&amp;rsquo;s own local cache.&#10;Everything outside the backup set is now &lt;em&gt;known&lt;/em&gt; disposable instead of &lt;em&gt;assumed&lt;/em&gt; disposable.&lt;/p&gt;&#10;&lt;p&gt;The second check was the restore path.&#10;A backup you have never restored from is a hypothesis.&#10;On a different machine, using only the recovery kit and no access to the PC at all, we restored a sample from the latest snapshot and compared hashes against the live system.&#10;The hashes matched byte for byte, and even the symlinks came back pointing at the right targets.&#10;A sample proves the path works, from credentials to symlink handling; reading back every byte is the third check&amp;rsquo;s job.&#10;The machine running the rehearsal had the kit and nothing else, so recovery provably does not depend on the machine being recovered.&lt;/p&gt;&#10;&lt;p&gt;The third check is the data itself.&#10;&lt;code&gt;restic check&lt;/code&gt; validates the repository structure without reading back the actual data blobs.&#10;Before the wipe, a &lt;code&gt;--read-data&lt;/code&gt; pass ran on the NAS, where the repository sits on local disk instead of behind the network.&#10;When the backup is the only rollback, I want its bits read back at least once.&lt;/p&gt;&#10;&lt;h2 id="the-fifth-question"&gt;The fifth question &lt;a class="anchor" href="#the-fifth-question" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The outage taught me a &lt;span class="hover-tip" data-tooltip="How do I find out when any of this stops working?" tabindex="0"&gt;fifth question&lt;/span&gt;: how do I find out when any of this stops working?&#10;All three checks above are point-in-time, and verification rots.&#10;So the NAS now &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/replication.nix#L263-L285" target="_blank" rel="noopener"&gt;re-verifies every hour&lt;/a&gt;, from its own side of the sftp connection, that the newest snapshot in the repository is less than a day old, and pushes an alert to my phone when it is not.&#10;That catches every failure mode on the PC side, including &amp;ldquo;the timer is simply disabled.&amp;rdquo;&#10;The watcher itself is watched: the NAS pings an &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/health.nix#L252-L280" target="_blank" rel="noopener"&gt;external dead-man&amp;rsquo;s switch&lt;/a&gt; every five minutes, so if the machine holding my only road back goes dark, the alert comes from outside the house.&lt;/p&gt;&#10;&lt;p&gt;While wiring this up we discovered that months of earlier NAS alerts had never arrived, because &lt;a href="https://github.com/basnijholt/dotfiles/blob/0d6101c8e3f91a0845cb41adb3cda216f50917c8/configs/nixos/hosts/nas/health.nix#L60-L67" target="_blank" rel="noopener"&gt;ntfy silently rejects message bodies over 4 KB&lt;/a&gt;.&#10;A notification you have never received is also a hypothesis.&lt;/p&gt;&#10;&lt;h2 id="restoring-in-stages"&gt;Restoring in stages &lt;a class="anchor" href="#restoring-in-stages" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The measured restore set came out to roughly 1 TB, and 99.9% of it is &lt;code&gt;/home&lt;/code&gt;.&#10;But two directories are 64% of that terabyte: Steam (430 GB of re-downloadable games) and my local-AI model collection (217 GB of GGUF files).&lt;/p&gt;&#10;&lt;p&gt;I do not need either of those to have a working machine.&#10;So the restore script now runs in stages:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./restore_from_backup.sh stage1 &lt;span class="c1"&gt;# everything else → a fully working machine&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo ./restore_from_backup.sh stage2 &lt;span class="c1"&gt;# Steam + models, whenever&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Stage 1 restores everything except the deferred paths, plus the system identity (host keys).&#10;Stage 2 can run days later, from the rebooted system, while I am already using the machine.&lt;/p&gt;&#10;&lt;p&gt;One detail I care about: stage 1 resolves &lt;code&gt;latest&lt;/code&gt; to a concrete snapshot ID and pins it to a file.&#10;Stage 2 reads the pin.&#10;My backups run every six hours, so without this, a timer firing between the stages would silently split the restore across two different snapshots.&lt;/p&gt;&#10;&lt;p&gt;The backup itself is untouched: Steam and the models are still in every snapshot.&#10;Only the restore order changed.&lt;/p&gt;&#10;&lt;h2 id="slimming-down-first"&gt;Slimming down first &lt;a class="anchor" href="#slimming-down-first" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The PC had also quietly become an Incus host for three LXC containers, including the &lt;a href="https://www.nijho.lt/post/mindroom/"&gt;MindRoom bots&lt;/a&gt; my family talks to every day.&#10;Before the migration, those containers moved to the NAS.&#10;That shrinks both the backup and the restore, and the family bots keep running while their old host is being wiped.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;Moving the containers produced its own lesson in btrfs size confusion.&#10;One container that measured &amp;ldquo;about 250 GB&amp;rdquo; shipped over 590 GB across the network overnight, because a directory that every disk-usage probe had failed to measure turned out to hold 2.5 TB of reflink-inflated build dependencies.&#10;The rule I took away: a directory that has &lt;em&gt;never been successfully measured&lt;/em&gt; is a red alert, not a rounding error.&#10;That kind of confusion is part of why this migration exists.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="the-cutover"&gt;The cutover &lt;a class="anchor" href="#the-cutover" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The install followed the same phased &lt;code&gt;nixos-anywhere&lt;/code&gt; pattern as the NAS cutover, driven from another machine on the LAN: kexec into an installer running in RAM, stop, re-verify the target disk from inside the installer, and only then run the destructive disko/install phase.&#10;That checkpoint is the whole point: right up to the destructive phase, aborting was still cheap.&#10;The destructive part turned out to be the trivial part: from wiping the disk to booting the new system took less than twenty minutes.&lt;/p&gt;&#10;&lt;p&gt;The one surprise of the day was in the restore, and it was exactly the kind rehearsal is supposed to catch.&#10;Stage 1 would not start: the restore script runs restic as root, and a freshly installed root has no SSH identity and no known_hosts.&#10;The key it needed sat in the recovery kit inside my home directory.&#10;The rehearsal never hit this, because the rehearsal machine&amp;rsquo;s root had already talked to the NAS.&#10;A verified restore path can still depend on state you forgot you had.&lt;/p&gt;&#10;&lt;p&gt;Stage 1 restored 534 GiB across 4.5 million files in just over an hour and a half, at wire speed.&#10;The machine rebooted with its old host keys and its old &lt;code&gt;/home&lt;/code&gt;, and stage 2 started immediately.&#10;The plan said stage 2 could wait for days; it started the minute the machine was back, because once the machine works there is no reason left to wait.&#10;The deferred 648 GiB of games and models took six more hours in the background.&lt;/p&gt;&#10;&lt;p&gt;With that, the PC finally joins the fleet pattern it was locked out of: sanoid snapshots the pool every hour, so a tired &lt;code&gt;rm -rf&lt;/code&gt;, mine or an agent&amp;rsquo;s, now costs at most an hour of work and a rollback, and a daily syncoid run sends the changed blocks to the NAS.&#10;restic is on borrowed time.&#10;Its six-hour timer keeps running until I trust the new path, and then it stops: the repository stays on the NAS as the last file-level snapshots of the machine, but nothing will be added to it.&lt;/p&gt;&#10;&lt;p&gt;Aftercare was the same short list, written down instead of remembered: revert the temporary root-SSH commit the installer needed, and put the restic password back where the backup service expects it.&lt;/p&gt;&#10;&lt;p&gt;The slow work, as always, was not the cutover.&#10;The slow work was making the cutover boring.&lt;/p&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;I&amp;rsquo;ve gone full Nix: Proxmox to NixOS + Incus&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/truenas-to-nixos/"&gt;Migrating from TrueNAS to NixOS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/homelab/"&gt;My homelab&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/pull/16" target="_blank" rel="noopener"&gt;The btrfs→ZFS migration PR&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/pull/67" target="_blank" rel="noopener"&gt;The PR that closed the silent backup-monitoring failure modes&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/nix-community/disko" target="_blank" rel="noopener"&gt;disko&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/nix-community/nixos-anywhere" target="_blank" rel="noopener"&gt;nixos-anywhere&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://restic.net/" target="_blank" rel="noopener"&gt;restic&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/jimsalterjrs/sanoid" target="_blank" rel="noopener"&gt;Sanoid&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>ZFS Unlock</title><link>https://www.nijho.lt/project/zfs-unlock/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/project/zfs-unlock/</guid><description/></item><item><title>Migrating from TrueNAS to NixOS without losing data after the build-system rug pull</title><link>https://www.nijho.lt/post/truenas-to-nixos/</link><pubDate>Sun, 28 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/truenas-to-nixos/</guid><description>&lt;p&gt;TrueNAS was the last non-NixOS machine in my home lab.&lt;/p&gt;&#10;&lt;p&gt;I kept postponing it for a simpler reason: moving something with ~100 TiB of data feels scary.&#10;My NAS holds the big ZFS pools.&#10;It has the SMB shares, NFS exports, snapshots, replication tasks, encrypted datasets, Incus state, Docker workloads, and all the boring-but-important health checks that are easy to take for granted until something fails.&lt;/p&gt;&#10;&lt;p&gt;I had already migrated most of my machines from Proxmox to NixOS with Incus, as I wrote about in &lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;I&amp;rsquo;ve gone full Nix: Proxmox to NixOS + Incus&lt;/a&gt;.&#10;That migration made me increasingly intolerant of appliance-style infrastructure.&#10;If the important state lives in a database behind a web UI, I cannot review it in a pull request.&#10;If I change something while debugging, I cannot guarantee the change made it back into the source of truth.&#10;And if I let an AI agent help me, imperative systems become even more uncomfortable: an agent can run a hundred commands, succeed, and leave behind a system that nobody can reproduce.&#10;For the NAS specifically, that basically meant I did not dare touch the machine with an AI agent at all.&lt;/p&gt;&#10;&lt;p&gt;NixOS is different.&#10;The diff is the state.&#10;If an agent changes my infrastructure, I can review the patch.&#10;If the change is wrong, I can reject it.&#10;If it works, I can commit it.&lt;/p&gt;&#10;&lt;p&gt;TrueNAS was the last holdout.&#10;It had been itching for a while.&#10;Then the &lt;a href="https://forums.truenas.com/t/clearing-the-air-on-build-scripts/64357" target="_blank" rel="noopener"&gt;build-system rug pull&lt;/a&gt; made the decision easy.&lt;/p&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#the-itch-before-the-last-straw"&gt;The itch before the last straw&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-last-straw"&gt;The last straw&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#why-this-was-scarier-than-proxmox"&gt;Why this was scarier than Proxmox&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#giving-an-agent-the-keys-carefully"&gt;Giving an agent the keys, carefully&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-nixos-scaffold"&gt;The NixOS scaffold&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-disko-problem"&gt;The disko problem&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#rehearsing-the-dangerous-part-in-a-vm"&gt;Rehearsing the dangerous part in a VM&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-parts-that-are-not-just-storage"&gt;The parts that are not just storage&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#secrets-stay-out-of-the-repo"&gt;Secrets stay out of the repo&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#where-this-leaves-truenas"&gt;Where this leaves TrueNAS&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-cutover"&gt;The cutover&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#references"&gt;References&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="the-itch-before-the-last-straw"&gt;The itch before the last straw &lt;a class="anchor" href="#the-itch-before-the-last-straw" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The build-system change was not the first thing that bothered me.&#10;It was just the last straw.&lt;/p&gt;&#10;&lt;p&gt;The deeper problem was that my TrueNAS setup had slowly become complicated in exactly the way I dislike.&#10;I had clicked many buttons in the UI over time:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;SMB shares&lt;/li&gt;&#10;&lt;li&gt;NFS exports&lt;/li&gt;&#10;&lt;li&gt;snapshot tasks&lt;/li&gt;&#10;&lt;li&gt;replication tasks&lt;/li&gt;&#10;&lt;li&gt;users and groups&lt;/li&gt;&#10;&lt;li&gt;encrypted datasets&lt;/li&gt;&#10;&lt;li&gt;apps and containers&lt;/li&gt;&#10;&lt;li&gt;Incus storage&lt;/li&gt;&#10;&lt;li&gt;old Jailmaker/systemd-nspawn state&lt;/li&gt;&#10;&lt;li&gt;alerting&lt;/li&gt;&#10;&lt;li&gt;UPS settings&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;TrueNAS can export and import its configuration.&#10;That is genuinely useful.&#10;But a database export is not the same thing as a configuration I can comfortably review.&#10;When I look at a TrueNAS backup, it is not obvious to me which settings I intentionally changed, which settings are defaults, which settings are historical leftovers, and which settings only exist because I clicked something while debugging two years ago.&lt;/p&gt;&#10;&lt;p&gt;One concrete example was NFS.&#10;I needed the &lt;code&gt;crossmnt&lt;/code&gt; export option so child datasets would show up correctly inside the Docker container host, but TrueNAS did not expose that option in the UI.&#10;So I had a periodic root cron job patching &lt;code&gt;/etc/exports&lt;/code&gt; after TrueNAS generated it.&#10;It worked, but it was exactly the kind of small appliance hack that made me uneasy: partly outside the UI, partly outside the exported configuration, and very easy to forget during a migration.&#10;In NixOS, that behavior is just part of the export line.&lt;/p&gt;&#10;&lt;p&gt;That is the exact discomfort that pushed me away from Proxmox.&#10;I do not want to remember what I clicked.&#10;I want the system to remember it as code.&lt;/p&gt;&#10;&lt;p&gt;The second itch was virtualization.&#10;My TrueNAS container story had already gone through one migration.&#10;For a while I used &lt;a href="https://github.com/Jip-Hop/jailmaker" target="_blank" rel="noopener"&gt;Jailmaker&lt;/a&gt;, which is a clever script around &lt;code&gt;systemd-nspawn&lt;/code&gt; for running Linux &amp;ldquo;jails&amp;rdquo; on TrueNAS SCALE.&#10;It worked well enough for what I needed at the time: a Debian environment where I could run Docker workloads close to the data.&#10;But it also reinforced the feeling that I was building a fairly custom server on top of an appliance OS.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://www.truenas.com/docs/scale/25.04/" target="_blank" rel="noopener"&gt;TrueNAS 25.04 &amp;ldquo;Fangtooth&amp;rdquo;&lt;/a&gt; introduced experimental Containers, named Instances in the early 25.04 releases, and the &lt;a href="https://www.truenas.com/blog/truenas-fangtooth-25-04-1/" target="_blank" rel="noopener"&gt;25.04.1 announcement&lt;/a&gt; explicitly described Instances as being based on Incus, supporting both VMs and LXCs.&#10;That was interesting to me because the older TrueNAS VM system was libvirt-based, while Incus felt like a more coherent container/VM story.&#10;Then the &lt;a href="https://forums.truenas.com/t/truenas-virtualization-plans-for-25-04-2/46236" target="_blank" rel="noopener"&gt;TrueNAS virtualization plan for 25.04.2&lt;/a&gt; explained that Fangtooth&amp;rsquo;s Instances feature caused challenges for some users and that 25.04.2 would re-enable the older Virtualization VM path alongside Instances.&#10;The &lt;a href="https://forums.truenas.com/t/truenas-25-04-2-is-now-available/49165" target="_blank" rel="noopener"&gt;25.04.2 release announcement&lt;/a&gt; made that split official: Virtual Machines came back, while VMs created through the earlier Instances feature continued to appear under Containers.&lt;/p&gt;&#10;&lt;p&gt;For many people that churn was probably annoying.&#10;For me it was especially ironic.&#10;I had migrated from Jailmaker&amp;rsquo;s &lt;code&gt;systemd-nspawn&lt;/code&gt; setup to Incus, read up on &lt;a href="https://linuxcontainers.org/incus/" target="_blank" rel="noopener"&gt;Incus&lt;/a&gt;, got excited about it, started using it, and then became genuinely happy with it when I migrated from Proxmox to NixOS with Incus.&#10;Incus fit my mental model perfectly: a clean CLI, containers and VMs in one place, easy scripting, and a workflow that agents can inspect.&lt;/p&gt;&#10;&lt;p&gt;Then TrueNAS started moving away from that direction again.&#10;Forum discussion around the 25.04.2 and later virtualization plans made it clear that Incus was not the stable long-term abstraction I hoped it would be inside TrueNAS.&#10;In the long-running &lt;a href="https://forums.truenas.com/t/linux-jails-containers-vms-with-incus/23599?page=21" target="_blank" rel="noopener"&gt;Linux Jails with Incus thread&lt;/a&gt;, Kris Moore is quoted saying that Incus is being removed from the base system and that libvirt will handle the backend for VMs and LXC.&#10;That probably makes sense for TrueNAS.&#10;They have enterprise users, compatibility concerns, and a support matrix that is very different from mine.&lt;/p&gt;&#10;&lt;p&gt;But for me it meant the NAS was again becoming an appliance whose internal direction I had to follow.&#10;I had just escaped that with Proxmox.&#10;I did not want to do it again for the most important machine in my house.&lt;/p&gt;&#10;&lt;h2 id="the-last-straw"&gt;The last straw &lt;a class="anchor" href="#the-last-straw" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Then I saw Jeff Geerling mention TrueNAS&amp;rsquo;s recent &amp;ldquo;shady behavior&amp;rdquo; in a video, and I started looking into what had actually changed.&lt;/p&gt;&#10;&lt;p&gt;The short version is that iXsystems made part of the TrueNAS ecosystem closed source.&#10;Specifically, iXsystems moved the TrueNAS build scripts and related build infrastructure internal for the TrueNAS 27 line.&#10;Their forum post, &lt;a href="https://forums.truenas.com/t/clearing-the-air-on-build-scripts/64357" target="_blank" rel="noopener"&gt;Clearing the Air on Build Scripts&lt;/a&gt;, frames this as a practical build-system decision and says normal installation and updates remain unaffected.&#10;The old &lt;a href="https://github.com/truenas/scale-build" target="_blank" rel="noopener"&gt;&lt;code&gt;truenas/scale-build&lt;/code&gt;&lt;/a&gt; repository now says it is preserved historically and is no longer the maintained build system.&#10;Community reactions, for example &lt;a href="https://forums.truenas.com/t/truenas-closing-its-build-system-has-sparked-a-significant-trust-crisis-in-the-self-hosted-and-engineering-communities/64549" target="_blank" rel="noopener"&gt;this TrueNAS forum thread&lt;/a&gt;, understandably read this as a meaningful step away from fully open development.&lt;/p&gt;&#10;&lt;p&gt;Would I ever have built TrueNAS from source myself?&#10;Almost certainly not.&lt;/p&gt;&#10;&lt;p&gt;That misses the point.&#10;I also rarely compile my Linux kernel manually, yet I care deeply that the ecosystem is open enough that I &lt;em&gt;could&lt;/em&gt; understand, rebuild, inspect, and fork the pieces if necessary.&#10;For a storage appliance, trust matters more than convenience.&#10;The NAS is not a toy VM.&#10;It is the machine that owns my data.&lt;/p&gt;&#10;&lt;p&gt;That was the final push.&#10;The UI state bothered me.&#10;The virtualization churn bothered me.&#10;The build-system change made the decision obvious.&lt;/p&gt;&#10;&lt;h2 id="why-this-was-scarier-than-proxmox"&gt;Why this was scarier than Proxmox &lt;a class="anchor" href="#why-this-was-scarier-than-proxmox" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Migrating Proxmox to NixOS was scary in the normal infrastructure sense.&#10;I had containers and VMs to move, but the blast radius was understandable.&#10;I could export with &lt;code&gt;vzdump&lt;/code&gt;, import into Incus, and test a lot of it in virtual machines.&lt;/p&gt;&#10;&lt;p&gt;Migrating TrueNAS is a different kind of scary.&#10;The data is not a VM disk that I can casually copy around.&#10;The important thing is &lt;em&gt;not&lt;/em&gt; to recreate the data pools.&#10;The important thing is to &lt;strong&gt;not touch them&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;The target architecture is simple in principle:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-nix" data-lang="nix"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Conceptually:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - disko owns only the boot disk&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - existing data pools are imported by name&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;zfs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;extraPools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;ssd&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;tank&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;];&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The NixOS boot disk can be destroyed and recreated.&#10;The existing &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; pools cannot.&lt;/p&gt;&#10;&lt;p&gt;This is the part where many migration guides become hand-wavy:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Install the new OS.&lt;/li&gt;&#10;&lt;li&gt;Import your ZFS pools.&lt;/li&gt;&#10;&lt;li&gt;Good luck.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;That is not enough for me.&#10;I could physically access the machine, but unplugging the relevant NVMe hardware would mean taking apart more of the box than I wanted to touch.&#10;So I treated the cutover as if the disks had to stay connected.&#10;And I absolutely did not want one typo in a disk path to become yet another meme in AI agent failures.&lt;/p&gt;&#10;&lt;h2 id="giving-an-agent-the-keys-carefully"&gt;Giving an agent the keys, carefully &lt;a class="anchor" href="#giving-an-agent-the-keys-carefully" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Without AI this would probably have become a week-long project that I would forever postpone.&#10;With AI, it felt plausible to get the first serious version done in a single sitting.&#10;My approach was to give GPT 5.5 in xhigh mode SSH access to the live TrueNAS box and tell it, repeatedly and very explicitly, not to change anything.&#10;To be clear, it had root access.&#10;At this point these models have become powerful enough that I trust them to follow explicit instructions.&#10;The safety boundary was instruction-following, narrow scope, and review.&lt;/p&gt;&#10;&lt;p&gt;Not &amp;ldquo;please be careful.&amp;rdquo;&#10;More like:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;You can inspect.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;You cannot change anything.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not start or stop services.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not import or export pools.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not load keys.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Do not mount datasets.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Read only.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is the part where people either nod along or think I have completely lost my mind.&#10;I think the distinction matters.&#10;I did not ask an agent to &amp;ldquo;migrate my NAS.&amp;rdquo;&#10;I asked it to inspect the current system, summarize the live state, compare that against my NixOS repository, and propose declarative config.&lt;/p&gt;&#10;&lt;p&gt;Then I reviewed the diffs.&#10;Then I asked for another review.&#10;Then another.&#10;The process was messier than one neat two-agent pass: build a scaffold, ask a model to attack it, compare that against the live TrueNAS state, refine the scaffold, and repeat.&#10;GPT 5.5 did most of the initial construction.&#10;Claude Opus 4.8 on max effort did one of the later independent cross-checks against the live system.&#10;By the end, I trusted the result because multiple independent reviews had converged on the same assumptions, and I understood the remaining differences.&#10;The loop felt like code review: inspect live state, turn the desired parts into Nix, review the diff, and ask another model to attack it.&#10;The scariest assumption got a VM test, and the destructive step stayed small.&lt;/p&gt;&#10;&lt;p&gt;Byte-for-byte TrueNAS cloning was never the target.&#10;I wanted every important behavior explicit: storage import, shares, snapshots, replication, Incus recovery, monitoring, and the few places where NixOS intentionally differs from the old appliance.&#10;At that point, the NixOS config reproduced the parts of TrueNAS I actually depended on, and the remaining differences were deliberate.&lt;/p&gt;&#10;&lt;p&gt;The agent was excellent at inventory:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Which pools exist&lt;/li&gt;&#10;&lt;li&gt;Which datasets are encrypted&lt;/li&gt;&#10;&lt;li&gt;Which NFS exports are live&lt;/li&gt;&#10;&lt;li&gt;Which SMB shares exist&lt;/li&gt;&#10;&lt;li&gt;Which users and numeric IDs matter&lt;/li&gt;&#10;&lt;li&gt;What Incus storage pool is in use&lt;/li&gt;&#10;&lt;li&gt;What replication tasks exist&lt;/li&gt;&#10;&lt;li&gt;What SMART/NUT/health monitoring exists&lt;/li&gt;&#10;&lt;li&gt;What host ID ZFS expects&lt;/li&gt;&#10;&lt;li&gt;Which disk is the current boot pool&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Preserving everything blindly would have been wrong.&#10;The useful parts became code, and the dangerous parts became explicit.&lt;/p&gt;&#10;&lt;h2 id="the-nixos-scaffold"&gt;The NixOS scaffold &lt;a class="anchor" href="#the-nixos-scaffold" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The resulting NixOS host is split into small files:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hosts/nas/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── default.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── disko.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── hardware-configuration.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── health.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── identity.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── networking.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── nfs.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── replication.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── samba.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── storage.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── virtualization.nix&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;├── CUTOVER.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;└── PLANNING.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is exactly why I like NixOS.&#10;The migration stopped being a list of steps I needed to follow and became a &lt;a href="https://github.com/basnijholt/dotfiles/pull/61/" target="_blank" rel="noopener"&gt;pull request&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The important storage boundary is:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;disko.nix&lt;/code&gt; describes only the boot disk.&lt;/li&gt;&#10;&lt;li&gt;The data pools are imported by name.&lt;/li&gt;&#10;&lt;li&gt;ZFS force-import is disabled.&lt;/li&gt;&#10;&lt;li&gt;Boot-time encrypted credential prompts are disabled.&lt;/li&gt;&#10;&lt;li&gt;Encrypted datasets are unlocked manually after boot.&lt;/li&gt;&#10;&lt;li&gt;Sanoid/Syncoid replace TrueNAS snapshot and replication tasks.&lt;/li&gt;&#10;&lt;li&gt;SMB, NFS, Docker, Incus, SMART, ZED, NUT, Netdata, and exporters are all declared in code.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;The data pools are intentionally &lt;em&gt;not&lt;/em&gt; in disko.&#10;That is the entire point.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;The host is called &lt;code&gt;nas&lt;/code&gt; because that is the role I want to keep.&#10;TrueNAS was the old implementation.&#10;NixOS becomes the new one, while preserving the services and data I actually need.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="the-disko-problem"&gt;The disko problem &lt;a class="anchor" href="#the-disko-problem" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/nix-community/disko" target="_blank" rel="noopener"&gt;&lt;code&gt;disko&lt;/code&gt;&lt;/a&gt; is a fantastic tool.&#10;It lets you describe disks declaratively and then formats/partitions/mounts them.&#10;For a fresh NixOS install, it is exactly what I want.&lt;/p&gt;&#10;&lt;p&gt;This also fits how I install NixOS machines.&#10;I build a custom &lt;a href="https://github.com/basnijholt/dotfiles/blob/main/configs/nixos/installers/iso.nix" target="_blank" rel="noopener"&gt;installer ISO&lt;/a&gt;, put it on a USB stick, and boot from it.&#10;The image already has SSH enabled with my keys, flakes enabled, and the tooling needed to run the repo-defined &lt;code&gt;disko&lt;/code&gt; and &lt;code&gt;nixos-install&lt;/code&gt; flow.&#10;That turns the installer environment into part of the reviewed configuration, instead of another place where I have to remember which choices to make.&lt;/p&gt;&#10;&lt;p&gt;It is also exactly the tool that can ruin your day if pointed at the wrong disk.&lt;/p&gt;&#10;&lt;p&gt;My requirement was:&lt;/p&gt;&#10;&lt;blockquote&gt;&#10;&lt;p&gt;Use disko to destroy and recreate the boot disk, but prove it cannot touch the existing ZFS data pools.&lt;/p&gt;&#10;&lt;/blockquote&gt;&#10;&lt;p&gt;The static config already looked right.&#10;The generated disko script only referenced the boot device and generated partition labels.&#10;The data pools were only present under &lt;code&gt;boot.zfs.extraPools&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;But &amp;ldquo;looks right&amp;rdquo; is not enough when the downside is wiping a NAS.&lt;/p&gt;&#10;&lt;p&gt;So we built a VM rehearsal.&lt;/p&gt;&#10;&lt;h2 id="rehearsing-the-dangerous-part-in-a-vm"&gt;Rehearsing the dangerous part in a VM &lt;a class="anchor" href="#rehearsing-the-dangerous-part-in-a-vm" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The VM test creates a miniature version of the dangerous situation:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Attach several throwaway disks to a disposable NixOS VM.&lt;/li&gt;&#10;&lt;li&gt;Create a fake &lt;code&gt;boot-pool&lt;/code&gt; on one disk.&lt;/li&gt;&#10;&lt;li&gt;Create fake &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; ZFS pools on other disks.&lt;/li&gt;&#10;&lt;li&gt;Write sentinel files into the fake data pools.&lt;/li&gt;&#10;&lt;li&gt;Export the fake data pools.&lt;/li&gt;&#10;&lt;li&gt;Run the generated &lt;code&gt;nas&lt;/code&gt; disko script against the fake boot disk.&lt;/li&gt;&#10;&lt;li&gt;Confirm the boot disk now contains the new &lt;code&gt;zroot&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Confirm fake &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; still have their ZFS labels.&lt;/li&gt;&#10;&lt;li&gt;Re-import fake &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Read the sentinel files.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The check lives in the flake now:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; configs/nixos&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;nix build .#checks.x86_64-linux.nas-disko-safety&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It passed.&lt;/p&gt;&#10;&lt;p&gt;Passing that VM test gives me confidence in the generated Nix/disko logic.&#10;The read-only check on the live TrueNAS box gives me confidence in the configured disk target: the by-id path resolves to the 500 GB &lt;code&gt;boot-pool&lt;/code&gt; disk, and &lt;code&gt;tank&lt;/code&gt; and &lt;code&gt;ssd&lt;/code&gt; live on different devices.&#10;The installer preflight repeats that same check immediately before the destructive step.&lt;/p&gt;&#10;&lt;p&gt;This is the level of paranoia I want around storage migrations.&lt;/p&gt;&#10;&lt;h2 id="the-parts-that-are-not-just-storage"&gt;The parts that are not just storage &lt;a class="anchor" href="#the-parts-that-are-not-just-storage" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Once the &amp;ldquo;do not wipe the pools&amp;rdquo; boundary was in place, the rest of the migration became more normal infrastructure work.&#10;Still annoying, but normal.&lt;/p&gt;&#10;&lt;p&gt;SMB was not a byte-for-byte port.&#10;TrueNAS ships private Samba VFS modules and has its own ACL/middleware behavior.&#10;Stock NixOS Samba covers the parts I need with &lt;code&gt;fruit&lt;/code&gt;, &lt;code&gt;streams_xattr&lt;/code&gt;, and sensible share definitions, but it is not literally the same stack.&#10;So the plan calls out what must be tested from real clients: Time Machine, guest access, and photo/media access.&lt;/p&gt;&#10;&lt;p&gt;Snapshots and replication also changed.&#10;TrueNAS snapshot tasks become Sanoid policy.&#10;TrueNAS replication tasks become Syncoid systemd timers.&#10;Existing snapshots are preserved, but Sanoid will not magically prune old TrueNAS-named snapshots.&#10;That is a feature, not a bug.&#10;I would rather manually decide when old snapshots age out than have a new tool silently &amp;ldquo;clean up&amp;rdquo; history.&lt;/p&gt;&#10;&lt;p&gt;Incus is another subtle one.&#10;The storage pool lives on a data pool, so disko will not touch it.&#10;But a fresh NixOS install has a fresh Incus database.&#10;The plan is to run &lt;code&gt;incus admin recover&lt;/code&gt;, then apply a small declarative reconciler for the known instances.&#10;The state boundary has to be clear: preserve the storage dataset, recover the Incus database, then reconcile the instance config.&lt;/p&gt;&#10;&lt;p&gt;Health monitoring is also declared:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;SMARTD&lt;/li&gt;&#10;&lt;li&gt;ZFS Event Daemon&lt;/li&gt;&#10;&lt;li&gt;NUT/UPS client&lt;/li&gt;&#10;&lt;li&gt;Netdata bound locally&lt;/li&gt;&#10;&lt;li&gt;Prometheus exporters&lt;/li&gt;&#10;&lt;li&gt;A shared alert hook that can log, wall, and optionally send ntfy alerts&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;TrueNAS gives you many of these things in the UI.&#10;NixOS gives them to me as code.&lt;/p&gt;&#10;&lt;p&gt;I know using your NAS for other services is a bit of an anti-pattern.&#10;In an ideal world, the storage box just stores data and stays boring.&#10;But when I originally bought and configured this machine, it became the most powerful machine in my home lab.&#10;So it naturally started attracting the Docker workloads too.&#10;Most of those workloads are not resource intensive, and keeping them close to the data is genuinely convenient.&#10;At one point I was running more than a hundred containers on that machine and it barely made a dent.&#10;They ran inside LXC containers on Incus rather than directly on the TrueNAS host, which made it feel like a reasonable compromise for a long time.&lt;/p&gt;&#10;&lt;p&gt;The migration also became a chance to fix a real operational problem.&#10;My worst TrueNAS incident was a long OOM death spiral: no swap, too many unbounded containers, and services repeatedly getting killed and restarted.&#10;The philosophy came from Nix and open infrastructure.&#10;The OOM incident was the practical reminder that the NAS had become a general-purpose compute host.&#10;In the NixOS version, container memory limits, zram, earlyoom, and an explicit ZFS ARC cap are part of the configuration instead of being tribal knowledge or post-incident notes.&lt;/p&gt;&#10;&lt;h2 id="secrets-stay-out-of-the-repo"&gt;Secrets stay out of the repo &lt;a class="anchor" href="#secrets-stay-out-of-the-repo" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My dotfiles are public.&#10;That makes the migration more interesting.&lt;/p&gt;&#10;&lt;p&gt;The Nix config necessarily contains some operational facts: hostnames, share names, service definitions, and so on.&#10;But the plan deliberately keeps secrets and sensitive cutover material out of git.&lt;/p&gt;&#10;&lt;p&gt;For this migration, that means:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;ZFS dataset passphrases and unlock material stay off-box and outside the repo.&lt;/li&gt;&#10;&lt;li&gt;Replication private keys are installed manually under &lt;code&gt;/etc/ssh&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Inbound authorized keys for backup senders are installed during cutover.&lt;/li&gt;&#10;&lt;li&gt;Alerting secrets live in a runtime env file.&lt;/li&gt;&#10;&lt;li&gt;A local &lt;code&gt;~/nas-cutover/&lt;/code&gt; staging directory can exist, but it is explicitly not part of the repo and should not be casually read by agents.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;One important detail: the encrypted datasets use passphrase keys.&#10;In the TrueNAS setup I handled that with &lt;a href="https://github.com/basnijholt/truenas-unlock" target="_blank" rel="noopener"&gt;truenas-unlock&lt;/a&gt;, a small tool I wrote for this exact problem.&#10;The unlock material lives on another device; when that device is on the same network, it can unlock the datasets through the TrueNAS API.&#10;That gave me a lightweight hardware/network-presence factor: the NAS could boot, and the encrypted datasets only unlocked when the separate unlock device was present too.&lt;/p&gt;&#10;&lt;p&gt;That changes the migration footgun.&#10;The TrueNAS API path does not survive the cutover as-is, so I split the same idea out into &lt;a href="https://github.com/basnijholt/zfs-unlock" target="_blank" rel="noopener"&gt;zfs-unlock&lt;/a&gt;: the NixOS/OpenZFS version that talks to a restricted SSH receiver instead.&#10;During the migration I still kept an independent manual passphrase recovery path, because I do not want the automatic unlock mechanism to be the only way back into my data.&lt;/p&gt;&#10;&lt;h2 id="where-this-leaves-truenas"&gt;Where this leaves TrueNAS &lt;a class="anchor" href="#where-this-leaves-truenas" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I still like TrueNAS.&#10;It got me very far.&#10;It made ZFS approachable.&#10;It gave me a good NAS UI when I wanted a NAS UI.&#10;And if you want an appliance that mostly owns storage for you, it is still a very compelling project.&lt;/p&gt;&#10;&lt;p&gt;I like NixOS more for my own infrastructure.&#10;The most important machine in my house should live in reviewable code instead of mutable appliance state spread across a UI, a database, and old debugging decisions.&#10;NixOS and AI are a particularly good match for that.&#10;I can describe messy infrastructure intent in imprecise English, let the agent turn that into exact Nix code, and review the diff before it becomes system state.&#10;The prompt stays fuzzy.&#10;The machine state does not.&lt;/p&gt;&#10;&lt;p&gt;After the Proxmox migration, TrueNAS was the last major exception.&#10;This migration removes that exception.&lt;/p&gt;&#10;&lt;h2 id="the-cutover"&gt;The cutover &lt;a class="anchor" href="#the-cutover" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I ended up using a phased &lt;a href="https://github.com/nix-community/nixos-anywhere" target="_blank" rel="noopener"&gt;&lt;code&gt;nixos-anywhere&lt;/code&gt;&lt;/a&gt; cutover instead of the USB installer path.&#10;The important safety property stayed the same: first kexec into a temporary NixOS installer, stop there, run the disk preflight from inside that installer, and only then run the destructive &lt;code&gt;disko,install,reboot&lt;/code&gt; phase.&#10;Before the destructive phase, the box was running the installer in RAM and the disks had not been intentionally modified yet.&#10;That gave me a real checkpoint where aborting was still cheap.&lt;/p&gt;&#10;&lt;p&gt;I kept my hands on the few commands that actually changed the machine.&#10;I ran the &lt;code&gt;nixos-anywhere&lt;/code&gt; phases and the later &lt;code&gt;nixos-rebuild&lt;/code&gt; commands myself.&#10;The agent stayed next to it, reading the outputs, checking the assumptions, SSHing in to verify pool health, services, mounts, Incus state, NFS clients, and then turning any findings back into code or documentation.&#10;That split felt right to me: I pressed enter on the small number of irreversible commands, and the AI did the tedious verification work that I would otherwise be tempted to rush.&lt;/p&gt;&#10;&lt;p&gt;The actual migration was almost anticlimactic compared to the preparation.&#10;From the moment TrueNAS stopped being the running OS to having the Incus containers back with my hundred-plus services running again took less than 20 minutes.&#10;The slow work was not the cutover.&#10;The slow work was making the cutover boring.&lt;/p&gt;&#10;&lt;details&gt;&#10;&lt;summary&gt;The small things that still needed fixing during cutover&lt;/summary&gt;&#10;&lt;p&gt;There were a few small first-boot reconciliations. I am writing them down mostly for future readers who want to follow a similar path:&lt;/p&gt;&#10;&lt;ul&gt;&#10; &lt;li&gt;imported data-pool mountpoints needed to be reconciled to the &lt;code&gt;/mnt/...&lt;/code&gt; paths my clients and services already used;&lt;/li&gt;&#10; &lt;li&gt;encrypted datasets needed a better manual post-boot unlock helper, and the old TrueNAS API unlock flow needed a NixOS/OpenZFS successor;&lt;/li&gt;&#10; &lt;li&gt;Incus needed &lt;code&gt;incus admin recover&lt;/code&gt; because the storage pool survived but the new OS had a fresh Incus database;&lt;/li&gt;&#10; &lt;li&gt;one unprivileged recovered container needed the matching subordinate UID/GID ranges declared for its passthrough idmap;&lt;/li&gt;&#10; &lt;li&gt;&lt;code&gt;nas.local&lt;/code&gt; needed an explicit DNS record so my wildcard did not send it to a workload container;&lt;/li&gt;&#10; &lt;li&gt;the old PC job that backed up the TrueNAS API config needed to be removed;&lt;/li&gt;&#10; &lt;li&gt;the PC NFS mounts, Nix cache endpoints, and recovered Docker workload container all needed client-side validation.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Those findings went back into the cutover runbooks and the Nix config in a &lt;a href="https://github.com/basnijholt/dotfiles/pull/62"&gt;follow-up PR&lt;/a&gt;, so the notes are available for anyone who might benefit from them later.&lt;/p&gt;&#10;&lt;/details&gt;&#10;&lt;p&gt;At the end of the cutover, the machine was &lt;code&gt;nas&lt;/code&gt;, the ZFS pools were healthy, the NFS mounts worked from my PC, the Incus containers were recovered, and the Docker workloads were running again.&#10;SMB authentication, guest access, Time Machine, and normal media browsing from macOS Finder worked after one final share-root permission fix.&#10;At that point the migration was working end to end: the pools were healthy and the services I actually use were back.&lt;/p&gt;&#10;&lt;h2 id="references"&gt;References &lt;a class="anchor" href="#references" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/proxmox-to-nixos/"&gt;I&amp;rsquo;ve gone full Nix: Proxmox to NixOS + Incus&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/homelab/"&gt;My homelab: from a single Raspberry Pi to a Proxmox cluster and a dedicated NAS&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;On agentic coding&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/clearing-the-air-on-build-scripts/64357" target="_blank" rel="noopener"&gt;TrueNAS forum: Clearing the Air on Build Scripts&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/truenas/scale-build" target="_blank" rel="noopener"&gt;&lt;code&gt;truenas/scale-build&lt;/code&gt; on GitHub&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/truenas-closing-its-build-system-has-sparked-a-significant-trust-crisis-in-the-self-hosted-and-engineering-communities/64549" target="_blank" rel="noopener"&gt;TrueNAS forum: build-system trust crisis discussion&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.truenas.com/docs/scale/25.04/" target="_blank" rel="noopener"&gt;TrueNAS 25.04 documentation&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.truenas.com/blog/truenas-fangtooth-25-04-1/" target="_blank" rel="noopener"&gt;TrueNAS 25.04.1: Fangtooth Unification Gains Momentum&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/truenas-virtualization-plans-for-25-04-2/46236" target="_blank" rel="noopener"&gt;TrueNAS Virtualization Plans for 25.04.2&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/truenas-25-04-2-is-now-available/49165" target="_blank" rel="noopener"&gt;TrueNAS 25.04.2 release announcement&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://forums.truenas.com/t/linux-jails-containers-vms-with-incus/23599?page=21" target="_blank" rel="noopener"&gt;Linux Jails with Incus forum thread&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/Jip-Hop/jailmaker" target="_blank" rel="noopener"&gt;Jailmaker&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/truenas-unlock" target="_blank" rel="noopener"&gt;truenas-unlock&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/zfs-unlock" target="_blank" rel="noopener"&gt;zfs-unlock&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/dotfiles/pull/61" target="_blank" rel="noopener"&gt;The NAS NixOS scaffold PR&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>Friend‑to‑friend TrueNAS backups without the cloud</title><link>https://www.nijho.lt/post/truenas-remote-backups/</link><pubDate>Sat, 24 Aug 2024 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/truenas-remote-backups/</guid><description>&lt;h2 id="the-challenge-escaping-the-cloud"&gt;The Challenge: Escaping the Cloud &lt;a class="anchor" href="#the-challenge-escaping-the-cloud" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I recently wrote about my &lt;a href="https://www.nijho.lt/post/homelab/"&gt;homelab&lt;/a&gt; and how I use TrueNAS on my NAS for data storage.&#10;Most self-hosters are looking for ways to &amp;ldquo;de-cloud&amp;rdquo; themselves, taking control of their data away from big tech companies, and reducing monthly subscription fees.&#10;One subscription cost that many of us still rely on is cloud storage for backups.&#10;Personally, I use Rclone to back up my most essential data to Backblaze B2, but now that my data is growing, I&amp;rsquo;m looking for a more cost-effective solution.&lt;/p&gt;&#10;&lt;p&gt;Luckily, I have a friend who also uses TrueNAS and is looking for a similar solution.&lt;/p&gt;&#10;&lt;p&gt;TrueNAS offers built-in replication tasks that allow us to efficiently replicate our data to another TrueNAS system.&#10;Unfortunately, this requires root access to the remote system, which is a security risk neither of us are willing to take&lt;/p&gt;&#10;&lt;p&gt;So, how can we set up remote ZFS replication with a friend, maintaining our de-clouded status, without compromising the security of our TrueNAS systems?&lt;/p&gt;&#10;&lt;h2 id="the-requirements"&gt;The Requirements &lt;a class="anchor" href="#the-requirements" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Use TrueNAS&amp;rsquo;s built-in replication tasks (uses &lt;code&gt;zfs send&lt;/code&gt; and &lt;code&gt;zfs receive&lt;/code&gt; for efficient replication, which is significantly faster than &lt;code&gt;rsync&lt;/code&gt;)&lt;/li&gt;&#10;&lt;li&gt;Replicate to a friend&amp;rsquo;s system with ZFS capabilities&lt;/li&gt;&#10;&lt;li&gt;Avoid granting root access to each other&amp;rsquo;s TrueNAS systems&lt;/li&gt;&#10;&lt;li&gt;Maintain control over the allocated backup space&lt;/li&gt;&#10;&lt;li&gt;Keep our data private and out of corporate clouds&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="the-solution-truenas-vm-on-hypervisor-and-iscsi-backup-storage-on-truenas"&gt;The Solution: TrueNAS VM on hypervisor and iSCSI Backup Storage on TrueNAS &lt;a class="anchor" href="#the-solution-truenas-vm-on-hypervisor-and-iscsi-backup-storage-on-truenas" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;After exploring various options, we&amp;rsquo;ve found a secure and efficient solution using a hybrid VM setup that keeps us firmly in control of our data. Here&amp;rsquo;s the high-level overview:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Deploy a TrueNAS VM on a local hypervisor (e.g., Proxmox), running independently of your main TrueNAS system&lt;/li&gt;&#10;&lt;li&gt;Set up an iSCSI target on your TrueNAS for backup storage&lt;/li&gt;&#10;&lt;li&gt;Connect the iSCSI target as an additional disk to the VM and format it with ZFS&lt;/li&gt;&#10;&lt;li&gt;Give your friend secure access to this VM&lt;/li&gt;&#10;&lt;li&gt;Your friend sets up replication tasks to this VM&amp;rsquo;s iSCSI-connected storage&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="why-this-works"&gt;Why This Works &lt;a class="anchor" href="#why-this-works" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt;: No need for root access on each other&amp;rsquo;s TrueNAS systems and data can be encrypted on the iSCSI storage with friend&amp;rsquo;s keys&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Performance&lt;/strong&gt;: VM OS runs from fast local storage, while backups use dedicated iSCSI storage&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Flexibility&lt;/strong&gt;: Easy to manage and update the VM independently of backup storage&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Efficiency&lt;/strong&gt;: Minimizes data transfer over iSCSI for system operations&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;De-Clouded&lt;/strong&gt;: Your data stays between you and your friend, no cloud services involved&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;p&gt;Regarding increasing storage capacity:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;While TrueNAS allows increasing the size of zvols on the fly, this approach has limitations for our VM setup.&lt;/li&gt;&#10;&lt;li&gt;A more robust scaling method is to add new iSCSI targets in Proxmox:&#10;&lt;ol&gt;&#10;&lt;li&gt;Create additional zvols on your main TrueNAS&lt;/li&gt;&#10;&lt;li&gt;Set up new iSCSI targets for these zvols&lt;/li&gt;&#10;&lt;li&gt;Add these new targets as virtual disks to your TrueNAS VM in Proxmox&lt;/li&gt;&#10;&lt;li&gt;Expand the ZFS pool in the VM by adding these new virtual disks&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;This method allows for more flexible growth of your backup storage, as you can add disks of various sizes and expand your pool incrementally.&lt;/li&gt;&#10;&lt;li&gt;It also maintains better performance, as multiple iSCSI targets can potentially use different network paths or interfaces.&lt;/li&gt;&#10;&lt;/ul&gt;&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="step-by-step-setup"&gt;Step-by-Step Setup &lt;a class="anchor" href="#step-by-step-setup" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;h3 id="on-your-main-truenas"&gt;On your main TrueNAS &lt;a class="anchor" href="#on-your-main-truenas" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Create a new zvol for backup storage&#10;&lt;ul&gt;&#10;&lt;li&gt;Navigate to Storage &amp;gt; Pools &amp;gt; [Your Pool] &amp;gt; zvols&lt;/li&gt;&#10;&lt;li&gt;Click &amp;ldquo;ADD zvol&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;Name it (e.g., &amp;ldquo;friend_backup&amp;rdquo;)&lt;/li&gt;&#10;&lt;li&gt;Set an appropriate size&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Set up an iSCSI target&#10;&lt;ul&gt;&#10;&lt;li&gt;Go to Sharing &amp;gt; Block Shares (iSCSI)&lt;/li&gt;&#10;&lt;li&gt;Click &amp;ldquo;Add&amp;rdquo; under Targets&lt;/li&gt;&#10;&lt;li&gt;Give it a name and create&lt;/li&gt;&#10;&lt;li&gt;Under Extents, click &amp;ldquo;Add&amp;rdquo;, select the zvol you created&lt;/li&gt;&#10;&lt;li&gt;Under Associated Targets, add your new target and extent&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="on-proxmox"&gt;On Proxmox &lt;a class="anchor" href="#on-proxmox" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Mount the iSCSI in Proxmox&#10;&lt;ul&gt;&#10;&lt;li&gt;Go to Datacenter &amp;gt; Storage &amp;gt; Add &amp;gt; iSCSI&lt;/li&gt;&#10;&lt;li&gt;Enter your TrueNAS IP and the target name&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Create a TrueNAS VM&#10;&lt;ul&gt;&#10;&lt;li&gt;Download TrueNAS ISO to &lt;code&gt;/var/lib/vz/template/iso&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;Click &amp;ldquo;Create VM&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;Select the TrueNAS ISO&lt;/li&gt;&#10;&lt;li&gt;In the &amp;ldquo;Hard Disk&amp;rdquo; step, add two SCSI disks:&#10;&lt;ul&gt;&#10;&lt;li&gt;One on local storage (for OS)&lt;/li&gt;&#10;&lt;li&gt;One on the iSCSI storage (for backups)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Install TrueNAS&#10;&lt;ul&gt;&#10;&lt;li&gt;Start the VM and open console&lt;/li&gt;&#10;&lt;li&gt;Install TrueNAS on the local disk&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;Configure TrueNAS VM&#10;&lt;ul&gt;&#10;&lt;li&gt;Access the TrueNAS web interface&lt;/li&gt;&#10;&lt;li&gt;Create a ZFS pool on the iSCSI disk&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="set-up-secure-access"&gt;Set up secure access &lt;a class="anchor" href="#set-up-secure-access" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Install and configure Tailscale (or preferred VPN)&#10;&lt;ul&gt;&#10;&lt;li&gt;On both your system and the TrueNAS VM&lt;/li&gt;&#10;&lt;li&gt;Use a self-hosted Headscale server for more control&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="for-your-friend"&gt;For your friend &lt;a class="anchor" href="#for-your-friend" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Provide VPN access to the TrueNAS VM&lt;/li&gt;&#10;&lt;li&gt;They can now set up replication tasks to the VM&amp;rsquo;s iSCSI storage using the TrueNAS web interface and their own encryption keys&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="repeat-the-process"&gt;Repeat the process &lt;a class="anchor" href="#repeat-the-process" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Follow the same steps on your friend&amp;rsquo;s system, reversing roles&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="considerations"&gt;Considerations &lt;a class="anchor" href="#considerations" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Network&lt;/strong&gt;: Ensure your internet connection can handle the data transfer&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Initial Seeding&lt;/strong&gt;: For large datasets, consider initial seeding in person&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Encryption&lt;/strong&gt;: Use encryption for the replicated datasets and VPN access for added security&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Testing&lt;/strong&gt;: Thoroughly test the setup before relying on it for critical backups&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Trust&lt;/strong&gt;: Choose your backup partner wisely - the other person will have access to the TrueNAS VM but not its data (if encrypted)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;VM Maintenance&lt;/strong&gt;: Regularly update and maintain the VM&amp;rsquo;s operating system&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion &lt;a class="anchor" href="#conclusion" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This friend-to-friend setup offers TrueNAS users an effective approach to remote backups. By creating mutual TrueNAS VMs, we&amp;rsquo;ve established a secure, decentralized backup solution with several advantages:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Privacy and Security&lt;/strong&gt;: Each friend has their own remote TrueNAS VM, with data remaining private when properly encrypted. Importantly, while you grant your friend access to your network, it&amp;rsquo;s limited to only the backup VM. You can firewall off the rest of your network, maintaining overall system security.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Full ZFS Feature Set&lt;/strong&gt;: This setup allows us to use ZFS features like snapshot-based backups, incremental replication, and data integrity checks.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Flexible Backup Strategies&lt;/strong&gt;: We can back up entire filesystems and maintain multiple snapshot versions.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;De-clouded Solution&lt;/strong&gt;: By partnering with a trusted friend, we&amp;rsquo;ve created a backup system that keeps our data out of corporate clouds, reducing costs and increasing control.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Scalability&lt;/strong&gt;: As data needs grow, we can add additional iSCSI targets in Proxmox and incorporate these new virtual disks into our ZFS pool.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Network Isolation&lt;/strong&gt;: Despite allowing access for backups, your main network remains protected. You control exactly what your backup partner can access, limiting potential security risks.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This solution represents a step towards data sovereignty and community-based storage. It&amp;rsquo;s secure, efficient, and independent of large tech companies, while still maintaining tight control over your network access.&lt;/p&gt;&#10;&lt;p&gt;Remember to regularly test your backup and restore processes, and periodically review your firewall rules. With this setup, you have a comprehensive, friend-to-friend backup solution that doesn&amp;rsquo;t compromise your overall network security.&lt;/p&gt;&#10;&lt;p&gt;Happy backing up!&lt;/p&gt;&#10;</description></item><item><title>My homelab: from a single Raspberry Pi to a Proxmox cluster and a dedicated NAS 🔧</title><link>https://www.nijho.lt/post/homelab/</link><pubDate>Sat, 06 Jul 2024 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/homelab/</guid><description>&lt;p&gt;In 2020, I began my homelab journey (&lt;em&gt;even though I never heard of the term &amp;ldquo;homelab&amp;rdquo; at that point&lt;/em&gt;) with a simple yet powerful setup: Home Assistant running on Proxmox on a NUC8i3BEH.&#10;My Home Assistant instance, with over 100 automations and about 20 addons (docker containers), had outgrown the capabilities of my Raspberry Pi 4, leading me to upgrade to the NUC8i3BEH.&#10;You can check out my &lt;a href="https://github.com/basnijholt/home-assistant-config/" target="_blank" rel="noopener"&gt;Home Assistant configuration&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;This system was more than sufficient for running Home Assistant—arguably overkill.&#10;However, because I passionately develop addons and custom components, I needed the ability to quickly restart and reload everything while debugging.&#10;For a long time, I ran Proxmox, mainly because the Home Assistant community recommended it, but I only used it to host one extra Ubuntu VM and Home Assistant OS.&lt;/p&gt;&#10;&lt;p&gt;In 2022, I stumbled upon the &lt;a href="https://github.com/tteck/Proxmox" target="_blank" rel="noopener"&gt;Proxmox VE Helper-Scripts&lt;/a&gt; by tteck, which got me interested in the whole homelabber/self-hosting community.&#10;These scripts opened a new world of possibilities.&#10;With them, I expanded my Proxmox environment to run 25 different VMs and &lt;a href="https://linuxcontainers.org/lxc/introduction/" target="_blank" rel="noopener"&gt;LXC containers&lt;/a&gt;, each performing a variety of tasks, from DNS servers and reverse proxies to local dashboards and backup software.&lt;/p&gt;&#10;&lt;p&gt;Initially, I used an external 2 TB drive for storage, but it quickly became insufficient as my data needs grew.&#10;After some research, I found the &lt;a href="https://support.hp.com/id-en/document/c06047207" target="_blank" rel="noopener"&gt;HP EliteDesk 800 G4 SFF&lt;/a&gt;, a renewed enterprise-grade desktop with slots for two 3.5-inch drives.&#10;At approximately $160 on Amazon, it offered excellent specs for a budget-friendly price.&#10;I decided to create a TrueNAS VM on this machine using Proxmox and passed through the two 16TB renewed enterprise-level HDDs (via the PCIe SATA controller), which I had purchased from serverpartdeals.com.&#10;I chose these drives based on their reliability as reported in the &lt;a href="https://www.backblaze.com/blog/backblaze-drive-stats-for-2023/" target="_blank" rel="noopener"&gt;BackBlaze drive stats&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;When I got the HP, I created a Proxmox cluster and was very excited about how easy it was to migrate containers and VMs from one machine to the other.&#10;However, I soon encountered a significant drawback: enterprise drives are notably loud.&#10;Since I had my Proxmox &amp;ldquo;cluster&amp;rdquo; (just the two machines) running behind the TV, the loudness was noticeable and disruptive.&#10;This noise was just the beginning of my troubles.&#10;The NFS server on the TrueNAS VM often crashed, causing the entire Proxmox system to lock up.&#10;Initially, I mounted the NFS drives directly on the Proxmox host and used bind-mounts to pass through the drives.&#10;Due to the locking behavior of NFS, any problem would cause the entire system to lock up.&#10;To prevent this, I started to use &lt;a href="https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/7/html/storage_administration_guide/nfs-autofs" target="_blank" rel="noopener"&gt;autofs&lt;/a&gt; to mount the drives in the LXC containers and VMs instead.&#10;This worked fine until Proxmox started its periodic backups.&#10;Any issue with the NFS during backup caused the whole process to hang, locking up the VMs and LXC containers.&#10;This could be partially avoided by using the &amp;ldquo;stop&amp;rdquo; backup mode, but it still resulted in almost daily debugging sessions where I had to restart Proxmox-related services.&lt;/p&gt;&#10;&lt;p&gt;After running all services as LXC containers, I realized that updating them and keeping up with all security updates was labor-intensive.&#10;So, I started to look into migrating everything to Docker Compose.&#10;This transition turned out great because I could manage my config files in a git repo and keep the relevant data separate, streamlining updates and maintenance.&lt;/p&gt;&#10;&lt;p&gt;To resolve these issues and because I was having a lot of fun, I decided to build a dedicated NAS running TrueNAS Scale, not that I &lt;em&gt;really&lt;/em&gt; needed one, but mostly because I could.&#10;This new setup included high-performance components, ensuring both stability and quiet operation.&#10;I used &amp;ldquo;Manufacturer Recertified&amp;rdquo; &lt;a href="https://www.westerndigital.com/products/internal-drives/data-center-drives/ultrastar-dc-hc550-hdd?sku=0F38356" target="_blank" rel="noopener"&gt;Western Digital Ultrastar DC HC550 HDDs&lt;/a&gt;, known for their reliability and performance.&#10;I have 6 x 16TB = 96TB now with the option to add 2 more 3.5&amp;quot; drives, 2 nvme M.2 drives, and 1 SATA 2.5&amp;quot; SSD.&#10;Now, I run all services that previously relied on NFS locally on TrueNAS via a Debian &lt;a href="https://wiki.debian.org/nspawn" target="_blank" rel="noopener"&gt;systemd-nspawn container&lt;/a&gt; managed with a new script called &lt;a href="https://github.com/Jip-Hop/jailmaker" target="_blank" rel="noopener"&gt;Jailmaker&lt;/a&gt;, which leverages the systemd-nspawn program in TrueNAS Scale (not to be confused with FreeBSD jails).&#10;Inside this systemd-nspawn container, I run Debian with Docker.&#10;For managing Docker Compose files, I use &lt;a href="https://github.com/louislam/dockge" target="_blank" rel="noopener"&gt;Dockge&lt;/a&gt;, which provides a nice web UI for easy management.&lt;/p&gt;&#10;&lt;p&gt;Along the way, I got familiar with ZFS and grew to love it for its amazing snapshotting and data integrity features.&#10;ZFS has been a game-changer, providing robust data protection and ease of management.&#10;Initially, &lt;a href="https://jrs-s.net/2015/02/06/zfs-you-should-use-mirror-vdevs-not-raidz/" target="_blank" rel="noopener"&gt;I used mirrored vdevs&lt;/a&gt;, but I recently switched to RAIDZ2 for the added storage capacity and &lt;a href="https://github.com/openzfs/zfs/pull/15022#issuecomment-1802428899" target="_blank" rel="noopener"&gt;the promise of OpenZFS 2.3 supporting expansion of RAIDZ vdevs&lt;/a&gt;.&#10;I also use ZFS for my Proxmox VMs and LXC containers, whose backups I store on the NAS via rsync in a cronjob to another jail running plain Debian (no more NFS on the Proxmox host for me&amp;hellip;)&lt;/p&gt;&#10;&lt;p&gt;This journey from a simple NUC running Home Assistant to a Proxmox cluster and dedicated NAS has been very time-consuming but a lot of fun.&#10;The lessons learned and the improvements in stability and performance have made the effort worthwhile.&#10;If you&amp;rsquo;re considering building your own homelab, investing in reliable hardware and being prepared for a bit of trial and error can make all the difference.&lt;/p&gt;&#10;&lt;h2 id="lessons-learned"&gt;Lessons learned &lt;a class="anchor" href="#lessons-learned" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Run your services in Docker containers, not LXC containers, for easier management and updates.&lt;/li&gt;&#10;&lt;li&gt;Do &lt;strong&gt;not&lt;/strong&gt; have virtualized NAS (TrueNAS VM) on Proxmox, it&amp;rsquo;s a bad idea if you are also sharing the drives with Proxmox via NFS. There are many folks on the internet who run their NAS in a VM on Proxmox, seemingly without issues, but I was not one of them.&lt;/li&gt;&#10;&lt;li&gt;Do not encrypt the root ZFS dataset! Encrypt its children instead.&lt;/li&gt;&#10;&lt;li&gt;Use passphrase encryption instead of a generated key. It allows you to use the UI and rotating keys.&lt;/li&gt;&#10;&lt;li&gt;Do &lt;strong&gt;not&lt;/strong&gt; remove the physical drive that is passed through in a Proxmox VM, because Proxmox will &lt;em&gt;not&lt;/em&gt; boot properly afterwards.&lt;/li&gt;&#10;&lt;li&gt;Do &lt;strong&gt;not&lt;/strong&gt; use random 100 character passwords for your Proxmox, you have only 60 seconds to enter it during boot.&lt;/li&gt;&#10;&lt;li&gt;When making Proxmox backups, send them to a different physical machine, not only the same machine that is being backed up.&lt;/li&gt;&#10;&lt;li&gt;When building a NAS or home server, consider noise levels, especially if the equipment will be in a living area. Enterprise-grade drives can be surprisingly loud.&lt;/li&gt;&#10;&lt;li&gt;When using Intel I226-V 2.5GbE controllers, be prepared for potential issues.&lt;/li&gt;&#10;&lt;li&gt;When planning your NAS storage size needs, remember to account for the extra space needed for redundancy (e.g., in RAIDZ2). Additionally, keep in mind that ZFS performs optimally when drives aren&amp;rsquo;t filled to capacity. It&amp;rsquo;s wise to plan for more storage than you think you&amp;rsquo;ll immediately need.&lt;/li&gt;&#10;&lt;li&gt;Buy as many used enterprise-grade hardware components as you can, they are often cheaper and more reliable than consumer-grade components.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="components"&gt;Components &lt;a class="anchor" href="#components" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For completeness, here are the components for each machine in my homelab:&lt;/p&gt;&#10;&lt;h3 id="nuc-components"&gt;NUC Components &lt;a class="anchor" href="#nuc-components" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Name&lt;/th&gt;&#10;&lt;th&gt;Full Name&lt;/th&gt;&#10;&lt;th&gt;Server&lt;/th&gt;&#10;&lt;th&gt;Price&lt;/th&gt;&#10;&lt;th&gt;Quantity&lt;/th&gt;&#10;&lt;th&gt;Cost&lt;/th&gt;&#10;&lt;th&gt;Date&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;NUC8i3BEH&lt;/td&gt;&#10;&lt;td&gt;Intel NUC Kit NUC8i3BEH&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;278.3&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;278.3&lt;/td&gt;&#10;&lt;td&gt;2020-02-22&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;500GB M2 SSD&lt;/td&gt;&#10;&lt;td&gt;Samsung 970 EVO M.2 500GB&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;94.99&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;94.99&lt;/td&gt;&#10;&lt;td&gt;2020-02-21&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;32 GB RAM&lt;/td&gt;&#10;&lt;td&gt;TEAMGROUP 32GB DDR4 3200MHz&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;58.99&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;58.99&lt;/td&gt;&#10;&lt;td&gt;2024-05-01&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;4TB SSD&lt;/td&gt;&#10;&lt;td&gt;Crucial P3 Plus 4TB NVMe&lt;/td&gt;&#10;&lt;td&gt;NUC&lt;/td&gt;&#10;&lt;td&gt;226.99&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;226.99&lt;/td&gt;&#10;&lt;td&gt;2024-04-27&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;!-- | 16 GB DDR4 | Crucial 8GB DDR4 2400 MT/s | NUC | 25.52 | 2 | 51.04 | 2020-02-21 | --&gt;&#10;&lt;h3 id="hp-components"&gt;HP Components &lt;a class="anchor" href="#hp-components" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Name&lt;/th&gt;&#10;&lt;th&gt;Full Name&lt;/th&gt;&#10;&lt;th&gt;Server&lt;/th&gt;&#10;&lt;th&gt;Price&lt;/th&gt;&#10;&lt;th&gt;Quantity&lt;/th&gt;&#10;&lt;th&gt;Cost&lt;/th&gt;&#10;&lt;th&gt;Date&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;64 GB RAM&lt;/td&gt;&#10;&lt;td&gt;TEAMGROUP 32GB DDR4 3200MHz&lt;/td&gt;&#10;&lt;td&gt;HP&lt;/td&gt;&#10;&lt;td&gt;57.99&lt;/td&gt;&#10;&lt;td&gt;2&lt;/td&gt;&#10;&lt;td&gt;115.98&lt;/td&gt;&#10;&lt;td&gt;2024-05-02&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;HP EliteDesk 800 G4 SFF&lt;/td&gt;&#10;&lt;td&gt;HP EliteDesk 800 G4 SFF&lt;/td&gt;&#10;&lt;td&gt;HP&lt;/td&gt;&#10;&lt;td&gt;164&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;164&lt;/td&gt;&#10;&lt;td&gt;2024-04-27&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;!-- | SSD enclosure | UGREEN SSD Enclosure | HP | 16.98 | 1 | 16.98 | 2024-04-27 | --&gt;&#10;&lt;p&gt;Notes on the HP components:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Initially, I planned to use this machine as a NAS because it has two 3.5&amp;quot; drive bays, 2 M.2 slots, and the disc drive bay can be converted to a 2.5&amp;quot; drive bay. However, the 2 x 16TB drives I bought quickly filled up because I used a mirrored vdev, so I decided to build a dedicated NAS instead.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="nas-components"&gt;NAS Components &lt;a class="anchor" href="#nas-components" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Name&lt;/th&gt;&#10;&lt;th&gt;Full Name&lt;/th&gt;&#10;&lt;th&gt;Server&lt;/th&gt;&#10;&lt;th&gt;Price&lt;/th&gt;&#10;&lt;th&gt;Quantity&lt;/th&gt;&#10;&lt;th&gt;Cost&lt;/th&gt;&#10;&lt;th&gt;Date&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;16 TB HDD&lt;/td&gt;&#10;&lt;td&gt;Western Digital Ultrastar DC HC550 16TB&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;159.99&lt;/td&gt;&#10;&lt;td&gt;2&lt;/td&gt;&#10;&lt;td&gt;319.98&lt;/td&gt;&#10;&lt;td&gt;2024-05-01&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;16 TB HDD&lt;/td&gt;&#10;&lt;td&gt;Western Digital Ultrastar DC HC550 16TB&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;187.5&lt;/td&gt;&#10;&lt;td&gt;4&lt;/td&gt;&#10;&lt;td&gt;750&lt;/td&gt;&#10;&lt;td&gt;2024-06-21&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Motherboard&lt;/td&gt;&#10;&lt;td&gt;Q670 8-bay NAS motherboard&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;203.21&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;203.21&lt;/td&gt;&#10;&lt;td&gt;2024-06-22&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;CPU i5 13500T&lt;/td&gt;&#10;&lt;td&gt;Intel Core i5-13500T&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;180.88&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;180.88&lt;/td&gt;&#10;&lt;td&gt;2024-06-22&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;PSU SFX&lt;/td&gt;&#10;&lt;td&gt;FSP Dagger Pro 650W&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;87.13&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;87.13&lt;/td&gt;&#10;&lt;td&gt;2024-06-21&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Jonsbo N3 case&lt;/td&gt;&#10;&lt;td&gt;JONSBO N3 Mini-ITX NAS Case&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;187.51&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;187.51&lt;/td&gt;&#10;&lt;td&gt;2024-06-16&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;CPU cooler&lt;/td&gt;&#10;&lt;td&gt;Thermalright AXP90 X36 CPU cooler&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;21.83&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;21.83&lt;/td&gt;&#10;&lt;td&gt;2024-06-25&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;64 GB DDR5 RAM&lt;/td&gt;&#10;&lt;td&gt;CORSAIR VENGEANCE 64GB DDR5&lt;/td&gt;&#10;&lt;td&gt;NAS&lt;/td&gt;&#10;&lt;td&gt;162.13&lt;/td&gt;&#10;&lt;td&gt;1&lt;/td&gt;&#10;&lt;td&gt;162.13&lt;/td&gt;&#10;&lt;td&gt;2024-06-29&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;Notes on the NAS components:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;The 13500T is a 35W TDP CPU I bought on eBay, which will help keep the system cool and quiet.&lt;/li&gt;&#10;&lt;li&gt;I bought the motherboard from AliExpress because it seems to be the only mini-ITX motherboard that supports 8 SATA drives, and also comes with 2x2.5GbE ports, 3xM.2 slots, and a full sized PCIe slot.&lt;/li&gt;&#10;&lt;li&gt;The motherboard uses Intel I225-V 2.5GbE controllers, which are notoriously bad, initially &lt;code&gt;iperf3&lt;/code&gt; maxed out at 100Mbps. The internet is full with people complaining about these controllers auto-negotiating to 100Mbps, however, for me it showed 1Gbps. After trying all suggestions I could find (there are few), as a last resort I connecting the NAS directly to my ASUS XT8 router instead of the NETGEAR GS305 switch, and now I got 1Gbps speeds. So I bought a new managed TL-SG108E switch, which now also gives me 1Gbps speeds. I&amp;rsquo;m not sure what the issue was, but I&amp;rsquo;m happy it&amp;rsquo;s resolved.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item></channel></rss>