nixbackup

ZFS backup discipline, learned the hard way, as enforced NixOS modules

Three independently toggleable modules that encode the invariants a ZFS replication setup needs to stay correct over time — not just on the day it was built. Each rule exists because a real, live setup violated it and paid for that violation. The fix was never “watch more closely”; it was making the wrong state structurally harder to reach, and verifying the right state instead of trusting anything’s exit code.

Three failure modes that hide behind a green dashboard

1

A mounted destination develops a phantom write

Mount a receive destination’s root and its children’s own mounts occlude the parent’s placeholder directories — a few bytes of directory-entry metadata churn that zfs receive’s incremental-safety check cannot tell apart from real data changing. The next incremental refuses with “destination has been modified since most recent snapshot”, even though every child shows zero bytes written. It reads exactly like data loss. It is not one. It still costs a manual re-baseline, and it recurs on every root a reboot or a rebuild remounts.

2

“Auto-create the destination” doesn’t create anything

Across more than one popular replication tool, the auto-create flag turns out not to actually create. A newly added child dataset under an already-replicating tree gets no destination at all — the tool logs “destination does not exist, will be rechecked every run” forever and never attempts the create, no matter how long you wait.

3

An exit code is not a signal

A replication job can exit 0 every single night while silently replicating nothing. A truncated btrfs receive can leave behind a directory with a perfectly fresh timestamp and no actual snapshot subvolume inside it. Both look identical to “healthy” from anything that isn’t reading the artifact’s structural ground truth.

Three modules, one per failure mode

destinations.nix

Every backup receive-destination dataset stays canmount=noauto + readonly=on, both set locally (a locally-set property always wins over one carried in by a received stream), plus actually unmounted. A boot and timer oneshot re-asserts all three, so nothing — not a stray zfs set, not a host rebuild that remounts the pool — can silently reopen the phantom-write divergence.

autobootstrap.nix

A timer that enumerates replication plans at runtime from ZFS user-properties — never a hardcoded list, because a plan absent from a hand-list is structurally invisible to anything built around that list — and seeds any destination child that does not exist yet with one zfs send -w | zfs receive -u. Everything it creates is immediately set canmount=noauto + readonly=on, so it starts life already compliant with the destinations invariant, whether or not that module also runs on the same host.

monitor.nix

The ground-truth freshness evaluator: it stats the artifact a job was supposed to produce, never the job’s exit code. Freshness reduces by per-leaf MIN, never a recursive MAX that lets one fresh child hide a dead subtree. Source and destination are diffed structurally, so a newly-missing child is its own failure rather than silence. Every result is published to a configurable push-style monitoring endpoint.

Husk detection

A numbered btrfs snapshot directory that exists with no snapshot subvolume ever having landed inside it reads as fresh by mtime and is actually empty. The monitor calls that a husk and fails it, because the name and the timestamp were never proof of a complete transfer.

Cadence-aware staleness

Deadlines follow the schedule the job actually runs on. A weekend evaluation still compares against the last weekday the job was expected to run, rather than an invented deadline that turns every Saturday into a false alarm.

Journal error budgets

A configurable per-unit journal check, because a unit can exit 0 while its own log already recorded a real per-item send or receive failure. Patterns, the since-window, and the destinations to exclude are all options.

How the three fit together

  SOURCE POOL                                DESTINATION POOL
  ───────────                                ────────────────
  tank/data/a  ─┐                          ┌─ tank/backups/a
  tank/data/b  ─┤  your replication tool   ├─ tank/backups/b
  tank/data/c  ─┘  (znapzend, syncoid, …)  └─ tank/backups/c   ← new child
       │                                            ▲
       │ org.nixbackup:enabled=on                   │ seeded by
       │ org.nixbackup:destination=…                │ send -w | receive -u
       │                                            │
       ├───────────────► autobootstrap ─────────────┘
       │                 (reads properties at runtime)
       │
       │                 destinations ──► canmount=noauto + readonly=on
       │                                  + unmounted, re-asserted on a timer
       │
       └───────────────► monitor ───────► stat the artifact, MIN-reduce,
                                          structural diff, push the verdict

Properties are the runtime contract

Your replication tool’s own config (or a small companion zfs set) stamps org.nixbackup:enabled=on and org.nixbackup:destination=<dest dataset> on each plan-root source dataset. That pair of properties is the entire contract the modules read — both property names are configurable options.

Independently toggleable

The modules share one namespace (nixbackup.*) and one convention, but no module depends on another being enabled. Take one, take all three; a typical host enables all three.

Deliberately out of scope

Which datasets to back up, and how often

This project has no opinion on your retention policy or your pool layout. It owns the invariants that make whatever you already run stay correct, not the policy decisions about what to run.

The replication daemon itself

nixbackup does not run or configure znapzend, syncoid, or anything else that schedules and executes zfs send/receive on your behalf. It assumes one of those — or an equivalent you wrote yourself — already exists and does the routine incremental work. nixbackup covers the three gaps those tools tend to share.

Status: pre-alpha, all three modules real

!

Extracted from a working setup

Each rule above corresponds to an incident that was actually hit, root-caused, and fixed on a live setup before being pulled out here, with every site-specific value replaced by a generic parameter.

!

Verified at module-evaluation level

Verified so far by NixOS module evaluation — nix eval on each nixosModules.* attribute, plus a toy nixosSystem importing all three together. Not yet run against a second, independent real ZFS pool.

!

Nothing claimed beyond that

Nothing advertised here is invented or missing, and nothing is claimed as battle-tested beyond that evaluation level until it has actually run elsewhere. Everything under Roadmap is future work, clearly marked as such.

Install

Add the flake input

inputs.nixbackup.url =
  "github:julian-corbet/nixbackup-corbet-ch";

Three flake outputs: nixosModules.destinations, nixosModules.autobootstrap, and nixosModules.monitor.

Enable what you need

modules = [
  nixbackup.nixosModules.destinations
  {
    nixbackup.destinations = {
      enable = true;
      datasets = [
        "tank/backups/dbs"
        "tank/backups/office"
      ];
    };
  }
];

Stamp the runtime contract

zfs set org.nixbackup:enabled=on tank/data
zfs set org.nixbackup:destination=tank/backups/data \
    tank/data

Only autobootstrap and the monitor’s zfs-dynamic targets read these. See examples/configuration.nix for a complete flake wiring all three modules on one host.

Monitor target kinds

zfs-leavesPer-leaf ZFS snapshot freshness over an explicit list of datasets, MIN-reduced so one fresh leaf cannot mask a dead one.
zfs-dynamicReplication plans discovered at runtime under a scanRoot, with a structural source-vs-destination diff. Supports a cadence block.
btrfs-receivedA flat tree of received btrfs snapshot subvolumes, husk-aware: a numbered directory with no subvolume inside it fails.
btrfs-mtimePlain mtime freshness for a btrfs path, when there is no subvolume structure to verify.
stampfileFreshness from a file a job writes on real completion — the escape hatch for anything the other kinds cannot see.

Roadmap

Receive-destination invariantDonemodules/destinations.nix.
Runtime-discovered auto-bootstrapDonemodules/autobootstrap.nix.
Ground-truth freshness monitorDonemodules/monitor.nix.
Second independent ZFS poolRun against a real pool that isn’t the one these were extracted from, beyond module evaluation. Planned.
lib property helperA helper for stamping the org.nixbackup:* properties, so a consumer doesn’t hand-write the zfs set calls. Planned.
Cadence documentationFull documentation of the cadence and journalChecks interaction beyond the inline option docs. Planned.