<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Backups | Bas Nijholt</title><link>https://www.nijho.lt/tag/backups/</link><atom:link href="https://www.nijho.lt/tag/backups/index.xml" rel="self" type="application/rss+xml"/><description>Backups</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>Backups</title><link>https://www.nijho.lt/tag/backups/</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>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>rsync-time-machine.py 🕒</title><link>https://www.nijho.lt/post/advent-of-open-source/03-rsync-time-machine/</link><pubDate>Tue, 03 Dec 2024 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/advent-of-open-source/03-rsync-time-machine/</guid><description>&lt;p&gt;(See my &lt;a href="../"&gt;intro post&lt;/a&gt;)&lt;/p&gt;&#10;&lt;p&gt;Sometimes the best open source projects start with &amp;ldquo;I can do better than this.&amp;rdquo; That&amp;rsquo;s exactly what happened when I looked at &lt;a href="https://github.com/laurent22/rsync-time-backup" target="_blank" rel="noopener"&gt;laurent22/rsync-time-backup&lt;/a&gt;, a popular 600-line Bash script for Time Machine-style backups. While it worked, it had known bugs and was tricky to maintain due to Bash&amp;rsquo;s limitations. It also lacked tests, type hints (bash duh&amp;hellip;), and proper error handling. So, I did what any reasonable developer would do: rewrote it in Python: &lt;a href="https://github.com/basnijholt/rsync-time-machine.py" target="_blank" rel="noopener"&gt;&lt;code&gt;rsync-time-machine.py&lt;/code&gt;&lt;/a&gt;! 🐍&lt;/p&gt;&#10;&lt;h2 id="-origin-story"&gt;📖 Origin Story &lt;a class="anchor" href="#-origin-story" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I needed reliable backups that worked across platforms and filesystems, unlike Apple&amp;rsquo;s Time Machine.&#10;While many excellent backup tools exist, I was drawn to the simplicity of this approach: it just creates folders named &lt;code&gt;YYYY-MM-DD-HH-MM-SS&lt;/code&gt; containing your files.&#10;No special tools needed for restoring or browsing - just your regular file explorer!&#10;I created a fully tested, typed, and documented Python port that maintains compatibility with the original while adding modern development practices.&lt;/p&gt;&#10;&lt;h2 id="-technical-highlights"&gt;🔧 Technical Highlights &lt;a class="anchor" href="#-technical-highlights" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Creates incremental backups using hard links (saving space while keeping full snapshots)&lt;/li&gt;&#10;&lt;li&gt;Works on Linux, macOS, and Windows (via WSL or Cygwin)&lt;/li&gt;&#10;&lt;li&gt;Supports local and remote (SSH) backups&lt;/li&gt;&#10;&lt;li&gt;Smart backup expiration strategy&lt;/li&gt;&#10;&lt;li&gt;Built-in safety checks and locking mechanism&lt;/li&gt;&#10;&lt;li&gt;Pretty terminal output&lt;/li&gt;&#10;&lt;li&gt;Absolutely zero dependencies beyond Python itself - it just works!&lt;/li&gt;&#10;&lt;li&gt;Usable as a standalone &lt;code&gt;.py&lt;/code&gt; script or pip installable package&lt;/li&gt;&#10;&lt;li&gt;100% test coverage&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="-impact"&gt;📊 Impact &lt;a class="anchor" href="#-impact" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The project reached the front page of &lt;a href="https://news.ycombinator.com/item?id=35933238" target="_blank" rel="noopener"&gt;Y Combinator&amp;rsquo;s Hacker News&lt;/a&gt;, causing its popularity to explode:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;381 GitHub stars&lt;/li&gt;&#10;&lt;li&gt;16 forks&lt;/li&gt;&#10;&lt;li&gt;6 contributors&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://realpython.com/podcasts/rpp/158/#t=1588" target="_blank" rel="noopener"&gt;Featured on Real Python podcast&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;Active discussion on Reddit&amp;rsquo;s &lt;a href="https://www.reddit.com/r/commandline/comments/13gtnrp/ported_a_popular_untested_600_line_bash_script_to/" target="_blank" rel="noopener"&gt;r/commandline&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h2 id="-challenges-and-solutions"&gt;🎯 Challenges and Solutions &lt;a class="anchor" href="#-challenges-and-solutions" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Maintaining compatibility with the original Bash script while improving the codebase&lt;/li&gt;&#10;&lt;li&gt;Making it work on Windows through WSL/Cygwin (surprisingly tricky!)&lt;/li&gt;&#10;&lt;li&gt;Handling various edge cases in filesystem operations&lt;/li&gt;&#10;&lt;li&gt;Implementing a robust backup expiration strategy&lt;/li&gt;&#10;&lt;li&gt;Adding comprehensive test coverage for file operations&lt;/li&gt;&#10;&lt;li&gt;Keeping the codebase dependency-free while maintaining functionality&lt;/li&gt;&#10;&lt;/ul&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;ol&gt;&#10;&lt;li&gt;Sometimes a rewrite is worth it, especially when adding modern development practices&lt;/li&gt;&#10;&lt;li&gt;Good documentation and test coverage are crucial for backup tools&lt;/li&gt;&#10;&lt;li&gt;The Python community appreciates well-structured ports of useful tools&lt;/li&gt;&#10;&lt;li&gt;Zero dependencies means zero headaches for users&lt;/li&gt;&#10;&lt;li&gt;Cross-platform compatibility (especially Windows) requires careful consideration&lt;/li&gt;&#10;&lt;li&gt;Simple, transparent solutions (like regular folders) can be better than complex ones&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Want reliable, cross-platform Time Machine-style backups? Check out &lt;a href="https://github.com/basnijholt/rsync-time-machine.py" target="_blank" rel="noopener"&gt;the project on GitHub&lt;/a&gt;!&lt;/p&gt;&#10;&lt;p&gt;#OpenSource #Python #Backups #DevTools #Programming&lt;/p&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></channel></rss>