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.
Two commands do the work:
# - queries each remote for its latest stored snapshot
# - resumes/starts snapshot transfer
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
zrb send works in two phases over a single SSH connection:
- Handshake — the server reports its most recent snapshot and any aborted transactions.
The client uses the server's head as the incremental base (or falls back to a full send if
the server has no snapshots). If a resume token is present for the same snapshot the client
is about to send,
zfs send -t <token>is used to pick up mid-stream. If the server's head is absent from the local history, the send fails with an error. - Transfer — the client streams the delta to
zfs receiveon the server. If the latest local snapshot is already on the server, to send is a no-op.
All connections are push from the client — the server runs only as an SSH ForceCommand.
Requirements
- Linux with ZFS executables (
zfsandzpoolinPATH) - SSH access from Source to Remote
- Rust toolchain (for source builds) or Nix
Installation
From crates.io:
With Nix:
Setup
1. Generate an SSH key on the Source
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:
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 dedicated users on the Remote
Two system users are needed: one exposed via SSH for receiving backups, one for running prune locally.
Copy the public key to the SSH user on the Remote:
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 the necessary permissions to each user on the dataset subtree:
Keeping destroy and mount in a dedicated prune user means the SSH-exposed zfsbackup account cannot delete
snapshots — zfs allow enforces this at the OS level regardless of what zrb does.
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:
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:
[]
# Discard stale interrupted-transfer state after this many days.
= 3
[]
# Datasets this client is allowed to receive into.
= ["backup/laptop/home", "backup/laptop/documents"]
= ["-c"]
[]
= 14
= 60
= 730
7. Write the Source config
~/.config/zrb/config.toml on the Source (default path; override with --config):
[]
= "my-laptop"
[]
= "backup.example.com"
= 22
= "zfsbackup"
= "/home/user/.ssh/id_zrb"
= ["-o", "ServerAliveInterval=30"]
= ["-Lec"]
# Map each local dataset to its destination path on each remote.
# Key = local dataset, value = map of remote-name → remote dataset.
[]
= "backup/laptop/home"
[]
= "backup/laptop/documents"
[]
= 7
= 30
= 365
Usage
All subcommands accept --verbose for debug logging and --config <path> to override the default config location.
zrb snapshot <dataset>...
Creates a zrb--prefixed snapshot locally without transferring it. Run this before zrb send.
zrb send <dataset>...
Connects to all configured Remotes and transfers the most recent local snapshot incrementally. If the remote already has the latest snapshot, the command succeeds silently with nothing to transfer. Resume tokens from interrupted transfers are detected and consumed automatically.
# Snapshot then send two datasets to all remotes
# Restrict to a single named remote
zrb list <dataset>
Lists all zrb-managed snapshots for a dataset.
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)
# Prune specific datasets only
# Preview what would be deleted without touching anything
# Abort a stuck in-progress resume transfer and prune anyway
--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>
# Create the snapshot first; if this fails the send is skipped.
ExecStartPre=/usr/local/bin/zrb snapshot tank/home tank/documents
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:
Check the last run:
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 two system users: zrb (SSH server user, receives backups) and zrb-prune (prune user, destroys
snapshots). It writes /etc/zrb/main/server.toml and adds a ForceCommand-restricted entry to the SSH 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 running as zrb-prune.
You still need to grant ZFS permissions imperatively. Using the default user names (user and prune.user override
them if changed):
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. Each send service runs zrb snapshot as ExecStartPre and
zrb send as ExecStart — if snapshot creation fails the send is skipped for that run.
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-restrictedauthorized_keysentry on the Remote - configures the SSH client on the Source so
hostresolves 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 boundary — zfs allow enforces at the OS level which datasets the backup user may
receive into, regardless of what zrb does.
SSH user and prune user are separated — the user invoked via SSH ForceCommand holds only receive,create,hold,release; it cannot destroy snapshots. Snapshot deletion (destroy,mount) is delegated exclusively to a separate prune user that is never reachable over SSH. A compromised SSH key therefore cannot wipe backup history.
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.