zrb — ZFS Remote Backup
zrb is a lightweight tool for pushing ZFS snapshots from a Source host (e.g. a laptop) to a Remote server over SSH. It
handles snapshot creation, incremental transfers, interrupted-transfer resumption, and snapshot pruning — all driven
from the Source side.
How it works
Source (laptop) ──SSH──► Remote (backup server)
zrb send zrb server (ForceCommand)
The Source creates a snapshot, opens an SSH connection, performs a structured handshake to negotiate an incremental
base, and streams the snapshot to the Remote via zfs send | zfs receive. The Remote never initiates contact. If a
transfer is interrupted mid-stream, the next zrb send resumes from where it left off using ZFS native resume tokens.
Requirements
- Linux with OpenZFS (
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; destroy is required for zrb prune.
3. Create a dedicated user on the Remote
Copy the public key to the Remote:
4. Configure SSH ForceCommand on the Remote
Edit /home/zfsbackup/.ssh/authorized_keys so that the key 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:
Keep the delegation as narrow as possible — per-dataset subtree, not the whole pool.
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 send <dataset>...
Creates a snapshot, connects to all configured Remotes, and transfers incrementally.
# Send two datasets to all remotes
# Restrict to a single named remote
If the previous transfer was interrupted, zrb send resumes it automatically before starting new sends.
zrb snapshot <dataset>...
Creates a zrb--prefixed snapshot locally without transferring it.
Useful for taking a local checkpoint before a risky operation.
zrb list <dataset>
Lists all zrb-managed snapshots for a dataset.
zrb prune <dataset>
Deletes snapshots that fall outside the Retention Policy. Runs locally — Source and Remote prune independently.
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
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" ];
};
};
}
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. You still need to grant ZFS permissions manually:
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-nightly 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 hand-written ForceCommand.
Import it alongside the client module and set noxa.enable = true on the remote:
{
imports = [
inputs.zrb.nixosModules.client
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.nightly = { 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; };
};
This requires that the Remote's NixOS config is evaluated in a multi-node context (e.g. deploy-rs,
colmena) that provides the nodes and nodeName special arguments. The module scans every other
node for zrb clients whose noxa.remotes.<name>.toNode equals this node's nodeName and
serverInstance matches the instance name, then derives clients.<sourceName>.allow from their
dataset mapping.
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
Three independent layers protect the Remote:
- ZFS delegation (
zfs allow) — the OS enforces which datasets the backup user may write to, regardless of whatzrbdoes. This is the authoritative boundary. - SSH key → client name binding — each
authorized_keysentry restricts which client names may connect with that key. A compromised key cannot impersonate unlisted clients. - Server config allowlist —
[clients.<name>].allowmaps each client to its permitted receive targets, catching misconfiguration early with a clear error message.
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.