
I’ve written twice about this NAS already — building it with RAIDZ1 on Debian 13, and then the addendum about how scrub and trim actually get scheduled, and the alert that wasn’t.
This one is about versioning. For years my answer to “how do I get yesterday’s version of this file back” has been rdiff-backup, and it’s a genuinely good answer. It’s still installed on this box:
$ rdiff-backup --version
rdiff-backup 2.2.6But building the pool on OpenZFS put a second mechanism on the table — one that solves the same problem from a completely different direction. So I spent an evening understanding how ZFS snapshots actually work, what they cost, and where they beat the tool I’ve been happily using. This post is that evening, including the parts I got wrong on the first attempt.
🔄 Two ways to answer the same question
Both tools answer “give me the state from N days ago.” They just disagree fundamentally about when the work happens.
rdiff-backup keeps a plain mirror of the newest state, plus reverse increments in rdiff-backup-data/. To make a backup it walks the entire source tree, compares it against the mirror, and computes binary deltas with librsync for whatever changed. The work happens at backup time, and it scales with how much data you have — not with how much of it changed. Restoring an old version means starting from the mirror and applying increments backwards.
ZFS snapshots do none of that, because the filesystem already knows. ZFS is copy-on-write: when you modify a block it never overwrites in place, it writes the new version elsewhere and repoints. The old block would normally become free space.
A snapshot says exactly one thing: keep every block this dataset currently points at.
There is nothing to scan, nothing to compare, nothing to compute. Which is why creating one looks like this:
$ zfs list -t snapshot -o name,used,creation
NAME USED CREATION
storage/user1@auto-2026-09-11_1250 0B Fr Sep 11 12:50 2026
storage/user2@auto-2026-09-11_1250 0B Fr Sep 11 12:50 2026
storage/nextcloud@auto-2026-09-11_1250 0B Fr Sep 11 12:50 2026
storage/user3@auto-2026-09-11_1250 0B Fr Sep 11 12:50 20260B. Four snapshots covering 1.8 TB, taken in milliseconds, occupying nothing. Everybody smiled, the picture was taken, and the film cost nothing — because there is no film. A snapshot isn’t a copy of your data. It’s a promise not to throw the current version away.
⚡ Is it actually faster? Yes, but be precise about what
Snapshot creation is O(1). It takes the same few milliseconds whether the dataset holds 14 GB or 1.17 TB, because the cost doesn’t depend on the data at all — only on freezing a set of references.
rdiff-backup has to traverse the tree every single run to discover what changed. On storage/user3 (1.17 TB) that’s a meaningful amount of I/O and wall-clock time even on a night where nothing moved.
Restore is the same story inverted. Every ZFS snapshot is a complete, directly readable tree — no reconstruction step, no applying increments, no waiting:
$ ls /storage/nextcloud/.zfs/snapshot/auto-2026-09-11_1250/
instance-a instance-bThat’s the state from that moment, browsable right now with ls, cp, rsync, or a file manager.
The honest caveat, because this is where the comparison gets oversold: a snapshot only versions data that is already on the pool. It does not move anything onto the NAS. Getting data from a server onto this box is still rsync’s job — the snapshot replaces the versioning layer, not the transfer. And rdiff-backup can write to a completely different machine over SSH, which snapshots categorically cannot. More on that below.
💾 And space?
Both approaches are efficient, but they’re efficient differently, and the honest answer has an “it depends” in it.
rdiff-backup stores compressed binary deltas at byte granularity. Change three rows in a database dump and the increment is tiny — genuinely impressive for that pattern.
ZFS retains whole changed records. On this pool that’s 128 KB:
$ zfs get recordsize,compression,compressratio storage/nextcloud
NAME PROPERTY VALUE
storage/nextcloud recordsize 128K
storage/nextcloud compression lz4
storage/nextcloud compressratio 1.03xSo a one-byte change to a huge file holds a 128 KB block, where rdiff-backup might have stored a few hundred bytes. Coarser, no argument.
What ZFS gets back in return:
- No mirror overhead. rdiff-backup’s newest state is a full second copy of everything. Snapshots share every unchanged block with the live data — there is no duplicate baseline at all.
- No per-version bookkeeping. Thirty snapshots of an unchanged dataset cost thirty times nothing.
- lz4 across the board, transparently, on live data and snapshots alike.
That compressratio of 1.03x is worth pointing at honestly: my data is photos and media, already compressed, so lz4 buys me almost nothing here. It’s not a knock on lz4 — it’s the correct result for this content, and it would look very different on a dataset full of logs or text.
Net result for typical mixed home-and-office data: comparable on space, dramatically cheaper operationally. For the specific pattern of tiny frequent changes inside very large files, rdiff-backup’s byte-level deltas still win on bytes.
📁 Where snapshots live: the directory that isn’t there
Snapshots aren’t files you can point a file manager at. But ZFS gives you a window that behaves like an ordinary directory:
$ ls /storage/nextcloud/.zfs/snapshot/
auto-2026-09-11_1250The catch that trips up everyone exactly once: .zfs is hidden by default and will not appear in ls -la. It isn’t a normal directory entry, it’s synthetic. Type the path anyway and it works.
$ zfs get snapdir storage/nextcloud
NAME PROPERTY VALUE SOURCE
storage/nextcloud snapdir hidden defaultEverything under there is read-only. You can’t damage a snapshot by browsing it and you can’t accidentally write into one. You can only copy out — which is exactly what you want during a recovery.
⏱️ The script, and why systemd timers this time
In the scrub-and-trim post I found Debian scheduling ZFS maintenance through /etc/cron.d/zfsutils-linux, with the systemd timers shipped but disabled. For snapshots I deliberately went the other way:
[Timer]
OnCalendar=daily
RandomizedDelaySec=15m
Persistent=truePersistent=true is the reason. If the NAS is powered off at midnight, cron simply skips that day and tells nobody. systemd runs the missed job at next boot. For something whose entire value is existing before you need it, silently skipping days is not an acceptable failure mode — and I’d just spent a whole post on things that fail silently.
RandomizedDelaySec staggers the four datasets so they don’t all fire on the same second.
The rotation script does one job: take a snapshot, then trim the oldest back to the retention count.
#!/bin/bash
# /usr/local/sbin/zfs-snapshot-rotate.sh
set -euo pipefail
DATASET="${1:?Dataset missing}"
KEEP="${2:-30}"
PREFIX="auto-"
zfs list -H -o name "$DATASET" >/dev/null 2>&1 || {
echo "Dataset $DATASET does not exist" >&2; exit 1; }
zfs snapshot "${DATASET}@${PREFIX}$(date +%Y-%m-%d_%H%M)"
# Newest first, keep the first $KEEP, everything after that goes.
# "|| true": grep returns 1 when nothing matches yet, which is not an error.
mapfile -t old < <(
zfs list -H -t snapshot -o name -S creation -r "$DATASET" 2>/dev/null \
| grep -E "^${DATASET}@${PREFIX}" \
| tail -n "+$((KEEP + 1))" || true
)
for snap in "${old[@]}"; do
[ -n "$snap" ] || continue
[[ "$snap" == "${DATASET}@${PREFIX}"* ]] || continue
[[ "$snap" == *"@"* ]] || continue
zfs destroy "$snap"
doneThree guards before anything is destroyed, and they’re not paranoia theatre. zfs destroy accepts a snapshot name and a dataset name in exactly the same argument position. If $snap were ever empty or truncated, zfs destroy storage/user3 would take 1.17 TB of family photos with it and ask no follow-up questions. The @ check makes that structurally impossible.
The prefix filter matters too: only snapshots this script created are eligible for deletion. Anything taken by hand, or by another tool, is invisible to it.
🧪 Testing the destroy path, because of course you test the destroy path
An untested zfs destroy inside a nightly timer pointed at your own data is not something you want to discover at an inconvenient moment. So before trusting it, I ran the rotation against the smallest dataset with KEEP=1, forcing it to actually delete something:
--- before ---
storage/user1@auto-2026-09-11_1252
--- rotate with keep=1 ---
--- after ---
storage/user1@auto-2026-09-11_1253
--- dataset intact? ---
storage/user1 14.4GOld snapshot gone, new one kept, dataset untouched. Verified rather than assumed.
One limitation I hit during testing and decided to keep: names carry %H%M, so two runs inside the same minute collide with “dataset already exists”. Irrelevant for a daily timer, a two-second puzzle for anyone testing by hand. Now you know.
🦠 The ransomware angle, which is where this really pays off
Several people sync to my Nextcloud with the Windows desktop client — the one that mounts the cloud as a drive in Explorer. That’s convenient, and it also means the client’s job description is “notice changed files, upload them.” Ransomware on such a machine encrypts the local files, and the client dutifully does what it was built to do.
This is the scenario where the difference between a mirror and a versioned history stops being academic. A pure mirror reproduces the encrypted state faithfully. A versioned history lets you step back past it.
The recovery order that actually works:
Stop the sync first. Disconnect the affected client, or put Nextcloud into maintenance mode. Restoring into a live sync relationship with an infected endpoint just gets your restore re-encrypted, and now you’ve spent time as well.
Try Nextcloud’s own layers next. Server-side trash and file versioning are by far the cheapest path, and for anything short of a mass event they’re usually enough on their own.
Then reach for snapshots. And here exactly one distinction matters:
zfs rollbackresets the dataset in place and irreversibly destroys everything newer, including newer snapshots. Misjudge the timing and your second attempt is gone.- Copying out of
.zfs/snapshot/<name>/changes nothing at all. Do it ten times, compare results, take your time.
Use the second one. Rollback is a sharp tool for a different job.
You also don’t have to guess when the damage started, which was my first instinct. ZFS will tell you:
$ zfs diff storage/nextcloud@auto-2026-09-10_0000 \
storage/nextcloud@auto-2026-09-11_0000The snapshot where thousands of files suddenly show as modified is the one after the event. Take the one before it.
📤 Where rdiff-backup still wins, and it’s not a small thing
Snapshots live in the pool. Same three disks, same fate. RAIDZ1 survives one dead disk; it does not survive a fire, a theft, a controller taking the array with it, or somebody with root running zfs destroy.
So snapshots defend against logical damage — ransomware, a fat-fingered deletion, a sync that went wrong. They do nothing about physical loss. That’s the half rdiff-backup has always covered well, writing over SSH to entirely separate hardware.
ZFS has its own answer here, and it composes nicely:
$ zfs send storage/nextcloud@auto-2026-09-11_1250 > /mnt/usb/nextcloud.zfs
$ zfs send storage/nextcloud@auto-2026-09-11_1250 | zfs recv usbpool/nextcloudzfs send -i then ships only the delta between two snapshots, which makes “external disk that lives in a drawer” practical rather than a weekend project.
The conclusion I landed on isn’t “replace rdiff-backup.” It’s that these tools were never really competing:
- Snapshots — instant, free, unlimited history against logical damage, right where the data lives
- A copy on separate hardware — the only thing that survives losing the machine itself
🔧 The commands actually worth remembering
Inspection:
$ zfs list -t snapshot -o name,used,creation # what exists, how big, when
$ zfs list -o name,used,avail -r storage # datasets and space
$ systemctl list-timers 'zfs-snapshot@*' # when did/will it run
$ journalctl -u 'zfs-snapshot@*' --since today # did it workBrowsing and recovering:
$ ls /storage/<dataset>/.zfs/snapshot/ # available points in time
$ ls /storage/<dataset>/.zfs/snapshot/auto-*/ # the files themselves
$ zfs diff <snapshot-a> <snapshot-b> # what changed between them
$ rsync -a /storage/x/.zfs/snapshot/<snap>/foo/ /restore/foo/Manual operations:
$ zfs snapshot storage/user3@before-something-risky
$ zfs destroy storage/user3@before-something-risky
$ zfs hold keep storage/user3@important # protect from deletionzfs hold is underrated. It makes a snapshot refuse to be destroyed until you explicitly release it — ideal for the one known-good state you want surviving any rotation logic, including your own.
⚠️ Two things to know before you rely on this
Retention is a real boundary. Thirty days here. An incident unnoticed for longer rotates out of reach. Comfortable for an actively used system; for cold archives, add a monthly tier rather than hoping.
Snapshots follow datasets, not directories. This one caught me while checking my own work. /storage holds four datasets — and one plain directory that is not a dataset, and therefore quietly gets nothing:
$ zfs list /storage/gitlab-migration
NAME USED AVAIL REFER MOUNTPOINT
storage 1.78T 1.73T 160K /storageIt resolved to the parent, not to itself. mkdir /storage/something tomorrow and it inherits no snapshot policy, with no warning. Use zfs create storage/something instead and it gets its own everything — snapshots, quotas, properties. Costs nothing, and it’s the difference between protection you have and protection you assume.
The actual takeaway
What struck me most while doing this wasn’t the space efficiency or even the speed. It was where the work happens.
rdiff-backup, like almost every backup tool, has to go and find out what changed — walking trees, comparing, computing. That’s an enormous amount of effort spent rediscovering something the filesystem watched happen in real time and then forgot.
ZFS doesn’t forget. Copy-on-write means the information was already there; a snapshot just declines to discard it. That’s why it’s instant, and it’s why it costs nothing until something actually changes.
Which is a good reminder that the interesting question about infrastructure usually isn’t “which tool is better.” It’s “who already knows the answer, and am I asking them — or recomputing it every night at 3 a.m.?”




