zrb 0.2.0

Incremental ZFS snapshot replication over SSH with resumable transfers and retention-based pruning
Documentation

zrb — ZFS Remote Backup

zrb automates what raw zfs send leaves to you: incremental base selection, interrupted-transfer resumption, snapshot management, and multi-remote delivery. NixOS modules for both server and client included — drop in and go.

One command does the work:

zrb send tank/home  # sends to all configured remotes
# - creates a new snapshot
# - queries each remote for its existing snapshots
# - selects the base that minimizes transferred data
# - resumes any interrupted transfer, then streams the delta

Pruning runs independently on each host according to its own retention policy.

How it works

Source (laptop)  ──SSH──►  Remote (backup server)
  zrb send                    zrb server (ForceCommand)

zrb send works in two phases over a single SSH connection:

  1. Handshake — the server reports its most recent snapshot; the client uses it as the incremental base (or falls back to a full send if the server has no snapshots). If the server's head is absent from the local history, the send fails with a clear error.
  2. Transfer — the client runs zfs send and pipes the stream to zfs receive on the server. If a previous transfer was interrupted, the client resumes it via ZFS native resume tokens before starting the next send.

All connections are push from the client — the server runs only as an SSH ForceCommand.

Requirements

  • Linux with ZFS executables (zfs and zpool in PATH)
  • SSH access from Source to Remote
  • Rust toolchain (for source builds) or Nix

Installation

From crates.io:

cargo install zrb

With Nix:

nix run github:0xCCF4/zrb

Setup

1. Generate an SSH key on the Source

ssh-keygen -t ed25519 -f ~/.ssh/id_zrb -C "zrb backup key"

2. Grant ZFS permissions on the Source

Delegate the minimum permissions to the user that will run zrb on each dataset you intend to back up:

zfs allow -u <user> snapshot,send,hold,release,destroy,mount tank/home

snapshot and send are required for zrb send; hold and release are required for Transfer Holds (protecting the last-sent snapshot from being pruned); destroy,mount is required for zrb prune. Instead of send you may grant send:raw to prevent encrypted datasets from being send unencrypted.

3. Create a dedicated user on the Remote

useradd -r -m -s /bin/bash zfsbackup

Copy the public key to the Remote:

ssh-copy-id -i ~/.ssh/id_zrb.pub zfsbackup@backup.example.com

4. Configure SSH ForceCommand on the Remote

Edit /home/zfsbackup/.ssh/authorized_keys so that the key always runs zrb server instead of a shell:

command="zrb server --client my-laptop",restrict ssh-ed25519 AAAA... zrb backup key

The --client flag lists which client names this key is permitted to present. A key may serve multiple clients: --client laptop --client desktop.

5. Grant ZFS permissions on the Remote

Delegate only the necessary permissions to the zfsbackup user on the dataset subtree it will receive into:

zfs allow -u <user> receive:append,create,hold,release,destroy,mount backup/laptop

Keep the delegation as narrow as possible — per-dataset subtree, not the whole pool.

After the first backup run has created the target datasets, set readonly=on on the parent to prevent accidental filesystem writes by any process on the Remote:

zfs set readonly=on backup/laptop

This has no effect on zfs receive or snapshot pruning — those operate at the ZFS layer, not the filesystem layer, and are unaffected by the property. Only normal file writes through the mounted filesystem are blocked.

Do not create the target datasets manually. zrb creates them automatically on the first transfer via zfs receive. Pre-existing datasets will cause zfs receive to fail.

6. Write the Remote config

~/.config/zrb/server.toml on the Remote:

[server]
# Discard stale interrupted-transfer state after this many days.
resume_hold_days = 3

[clients.my-laptop]
# Datasets this client is allowed to receive into.
allow = ["backup/laptop/home", "backup/laptop/documents"]
zfs_receive_opts = ["-c"]

[retention]
recent = 14
weekly_for_days = 60
monthly_for_days = 730

7. Write the Source config

~/.config/zrb/config.toml on the Source (default path; override with --config):

[source]
name = "my-laptop"

[remotes.primary]
host = "backup.example.com"
port = 22
user = "zfsbackup"
ssh_key = "/home/user/.ssh/id_zrb"
ssh_opts = ["-o", "ServerAliveInterval=30"]
zfs_send_opts = ["-Lec"]

# Map each local dataset to its destination path on each remote.
# Key = local dataset, value = map of remote-name → remote dataset.
[datasets."tank/home"]
primary = "backup/laptop/home"

[datasets."tank/documents"]
primary = "backup/laptop/documents"

[retention]
recent = 7
weekly_for_days = 30
monthly_for_days = 365

Usage

All subcommands accept --verbose for debug logging and --config <path> to override the default config location.

zrb send <dataset>...

Creates a snapshot, connects to all configured Remotes, and transfers incrementally.

# Send two datasets to all remotes
zrb send tank/home tank/documents

# Restrict to a single named remote
zrb send tank/home --remote primary

If the previous transfer was interrupted, zrb send --resume resumes it automatically.

zrb snapshot <dataset>...

Creates a zrb--prefixed snapshot locally without transferring it.

zrb snapshot tank/home tank/documents

Useful for taking a local checkpoint before a risky operation.

zrb list <dataset>

Lists all zrb-managed snapshots for a dataset.

zrb list tank/home

zrb prune

Deletes snapshots that fall outside the Retention Policy on the local host.

# Prune the datasets declared in the config file (client: datasets map; server: all allow lists)
zrb prune

# Prune specific datasets only
zrb prune tank/home tank/documents

# Preview what would be deleted without touching anything
zrb prune --dry-run

# Abort a stuck in-progress resume transfer and prune anyway
zrb prune --abort-resume

--dry-run prints each snapshot with its keep reason (daily, weekly, monthly, yearly) or a deletion marker. Snapshots carrying a Transfer Hold (zrb:*) are shown with a ⏸ marker — they are protected from deletion until the next successful send moves the hold. No ZFS mutations are made.

--abort-resume overrides the resume_hold_days guard on the Remote: it discards the pending resume token and prunes regardless. Use when a partially-received transfer is no longer worth resuming.

zrb server --client <name>...

Runs in server mode. Intended to be called only by SSH ForceCommand, not directly.

Automating with systemd

Create a service and timer on the Source to run backups on a schedule.

/etc/systemd/system/zrb-send.service

[Unit]
Description=ZFS remote backup
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User=<user>
ExecStart=/usr/local/bin/zrb send tank/home tank/documents
# Kill and restart if a single 4 MiB chunk takes longer than this to transfer.
WatchdogSec=1m

/etc/systemd/system/zrb-send.timer

[Unit]
Description=Run ZFS remote backup daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

Enable and start:

systemctl enable --now zrb-send.timer

Check the last run:

systemctl status zrb-send.service
journalctl -u zrb-send.service -n 50

NixOS

There are NixOS modules for easy integration of server and/or client.

Both modules are exposed as nixosModules.server and nixosModules.client from the flake.

Add to your flake.nix inputs:

inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  
  zrb = {
    url = "github:0xCCF4/zrb";
    inputs.nixpkgs.follows = "nixpkgs";
  };
};

Server

{
  imports = [ inputs.zrb.nixosModules.server ];

  services.zrb.server.main = {
    enable = true;

    retention = {
      recent = 14;
      weeklyForDays = 60;
      monthlyForDays = 730;
    };

    clients.my-laptop = {
      publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... zrb backup key";
      allow = [ "backup/laptop/home" "backup/laptop/documents" ];
    };

    # Optional: prune the server's backup datasets on a schedule.
    # Targets the union of all clients' allow lists.
    prune.onCalendar = "weekly";
  };
}

The module creates the zrb system user, writes /etc/zrb/main/server.toml, and adds a ForceCommand-restricted entry to the user's authorized_keys for each client that has a publicKey set. When prune.onCalendar is set, a zrb-server-prune-<name> systemd service and timer are generated. You still need to grant ZFS permissions imperatively.

Client

{
  imports = [ inputs.zrb.nixosModules.client ];

  services.zrb.client = {
    enable = true;
    sourceName = "my-laptop";

    remotes.primary = {
      host = "backup.example.com";
      user = "zrb";
      sshKey = "/etc/zrb/id_ed25519";
    };

    datasets = {
      "tank/home".primary      = "backup/laptop/home";
      "tank/documents".primary = "backup/laptop/documents";
    };

    retention = {
      recent = 7;
      weeklyForDays = 30;
      monthlyForDays = 365;
    };

    jobs.nightly = {
      onCalendar = "daily";
      datasets = [ "tank/home" "tank/documents" ];
      # Default is "1m". Increase for very slow links; set null to disable.
      watchdogSec = "1m";
    };

    prune.onCalendar = "weekly";
  };
}

The module creates the zrb system user, writes /etc/zrb/client.toml, and registers a zrb-send-<name> systemd service+timer and a zrb-prune service+timer. The SSH key at sshKey must be provisioned separately (e.g. via sops-nix or agenix).

noxa SSH integration

If you use noxa for SSH key lifecycle management, the optional nixosModules.noxa module wires up the SSH layer automatically — no manual key generation, no pasting public keys into the server config, no handwritten ForceCommand or manual SSH keys.

Import it instead of the client/server module and set noxa.enable = true on the remote:

{
  imports = [
    inputs.zrb.nixosModules.noxa
  ];

  services.zrb.client = {
    enable = true;
    sourceName = "my-laptop";

    remotes.primary = {
      # host defaults to the noxa SSH alias; set explicitly to override
      noxa = {
        enable = true;
        toNode = "backup-server";   # noxa node name of the Remote
        serverInstance = "main";    # matches services.zrb.server.<name> on the Remote
      };
    };

    datasets."tank/home".primary = "backup/laptop/home";
    
    retention = { recent = 7; weeklyForDays = 30; monthlyForDays = 365; };
    jobs.daily = { onCalendar = "daily"; datasets = [ "tank/home" ]; };
  };
}

The module declares a noxa SSH grant named zrb-<remoteName> for each noxa-enabled remote. noxa then:

  • generates the SSH keypair and distributes it via its secrets system
  • writes a ForceCommand-restricted authorized_keys entry on the Remote
  • configures the SSH client on the Source so host resolves correctly

The server-side zrb user is derived automatically from the Remote's NixOS config. Set noxa.toUser explicitly if you need to override it.

On the Remote, set noxa.enable = true on the server instance to have the module auto-discover clients from other nodes and populate their allow lists from the client's dataset mapping. Add publicKey is not needed — noxa owns that authorized_keys entry.

services.zrb.server.main = {
  enable = true;
  noxa.enable = true;   # auto-populates clients from nodes
  retention = { recent = 14; weeklyForDays = 60; monthlyForDays = 730; };
};

Additional per-client config (e.g. zfsReceiveOpts) merges in via the NixOS module system — set it alongside the auto-discovered entry:

services.zrb.server.main.clients.my-laptop = {
  # allow is populated automatically; add extra options here
  zfsReceiveOpts = [ "-c" ];
};

Configuration reference

Full documentation of all config keys for both source and server: docs/config.md.

Retention policy

The same [retention] block is used in both Source and Remote configs. Each host prunes independently.

Window Rule
Last N snapshots Always kept (recent)
Older, within weekly_for_days One per ISO week
Older, within monthly_for_days One per calendar month
Older still One per year

Only snapshots with the zrb- prefix are managed. Any snapshots you created manually are left untouched.

Security model

SSH as the transport — SSH provides an encrypted channel and key-based authentication. The pre-shared SSH key is the only credential the client needs; no passwords, no tokens.

ForceCommand invokes the remote zrb instance — instead of opening a shell, the SSH key directly starts zrb server on the Remote. The client never gets a shell; the connection is purpose-built for the backup protocol.

--client binds a key to specific source hosts — each authorized_keys entry declares which client names it may serve. A compromised key cannot impersonate a client (identified by its public key) name that is not listed, limiting the blast radius to the datasets that client is permitted to write.

ZFS delegation is the hard boundaryzfs allow enforces at the OS level which datasets the backup user may receive into, regardless of what zrb does.

Snapshot naming

All managed snapshots follow the pattern <dataset>@zrb-<ISO-8601-UTC>, e.g.:

tank/home@zrb-2026-05-22T14:30:00Z

zrb ignores all snapshots that do not match this prefix.

On the use of AI

This project was a long-standing concept and half finished prototype before any AI tools were used. When I recently tried Claude for the first time, I let it read the existing code and documentation, and in a supervised manner, used it to bring to project into a usable state. Note that every change was carefully reviewed and edited afterward by myself @0xCCF4.

License

Due to the use of AI in the development process, I cannot confidently assert that this project is free of "inspiration" from others' work, but I am not aware of any equivalent project, from which, AI might have copied major parts. Since the idea, starting code base, and so on where given by myself, I am for now releasing this project under the GPLv3 license into the open source community.

Contributions

Contributions are welcome! Please open an issue or submit a pull request.