Automated, incremental ZFS backups over SSH.
π Encrypted raw send β the receiver stores ciphertext and never sees your key
β»οΈ Resumable transfers β interrupted sends pick up where they left off
π Placeholder bookmarks β prune source snapshots without breaking incremental chains
ποΈ Configurable retention β keep hourly, daily, weekly, and monthly snapshots
π Prometheus monitoring β alert on stale snapshots before they matter
π¦ Zero external dependencies β pure Go stdlib, nothing to vendor or audit
Five independent modules. Mix and match β use only what you need. Or run them all together with zfsbackup run.
| Module | Purpose | |
|---|---|---|
| π | Run | Runs all configured modules in sequence from a single config file |
| πΈ | Snapshot | Creates ZFS snapshots on a schedule |
| π€ | Sender | Streams snapshots to a remote receiver over SSH |
| π₯ | Receiver | Accepts snapshot streams from a paired sender |
| ποΈ | Deleter | Prunes old snapshots with configurable retention rules |
| π | Monitor | Exports snapshot freshness metrics for Prometheus |
Note
Sender and Receiver are paired β they must be used together. All other modules are independent.
Tip
Logs from any module are available via journalctl -u zfsbackup-<module>.service.
zfsbackup run reads a single JSON config file and runs all configured modules in sequence: snapshot β deleter β sender β monitor. Modules with no section in the config are skipped. All modules run regardless of individual failures; errors are collected and reported together at the end.
zfsbackup run --config /etc/zfsbackup/mypool.jsonPass --dry-run to preview snapshot creation and deletion and skip the sender. Monitoring still runs and updates its configured output file.
Systemd units
# /etc/systemd/system/zfsbackup.service
[Unit]
Description=ZFS Backup
After=network-online.target zfs.target
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=0
ExecStart=/usr/local/bin/zfsbackup run --config /etc/zfsbackup/mypool.json --dry-run=false
# No [Install] section β this service is activated exclusively by the .timer unit# /etc/systemd/system/zfsbackup.timer
[Unit]
Description=ZFS Backup β hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now zfsbackup.timermypool.json
{
"include": ["mypool"],
"exclude": ["mypool/scratch"],
"snapshot": {
"name_pattern": "snap-2006-01-02_15-04-05"
},
"sender": {
"snapshot_re": "snap-....-..-.._..-..-..",
"destinations": [
{
"receiver": "ssh username@backuphost -- ",
"compression": "zstd",
"placeholders": ["primary"]
},
{
"receiver": "ssh username@offsite -- ",
"compression": "zstd",
"raw_send": true,
"placeholders": ["offsite"]
}
]
},
"deleter": {
"regex": ["snap-....-..-.._..-..-.." ],
"preserve_top_n": 5,
"preserve_newer_than": "3h",
"rules": [
{ "interval": "1h", "count": 48, "allow_holes": true },
{ "interval": "1d", "count": 30, "allow_holes": true },
{ "interval": "7d", "count": 12 },
{ "interval": "30d", "count": 12 }
]
},
"monitor": {
"prometheus_output": "/var/lib/node_exporter/textfile_collector/zfsbackup.prom"
}
}The top-level include and exclude fields provide defaults inherited by all modules. Individual module sections may override them with their own include/exclude.
Each invocation holds an exclusive lock on its config file until its work finishes, serializing processes that use the same file. Different config files need scheduling coordination if they manage the same datasets or placeholder suffixes. Receiver invocations do not lock their config file, allowing different datasets to receive in parallel.
Takes snapshots of configured filesystems. Typically run every 30 minutes.
Systemd units
# /etc/systemd/system/zfsbackup-snapshot.service
[Unit]
Description=ZFS Backup snapshot
After=zfs.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfsbackup snapshot --config /etc/zfsbackup/mypool.json
# User=backupuser # uncomment if using ZFS delegation
# No [Install] section β this service is activated exclusively by the .timer unit# /etc/systemd/system/zfsbackup-snapshot.timer
[Unit]
Description=ZFS Backup snapshot β every 30 minutes
[Timer]
OnCalendar=*:0/30
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now zfsbackup-snapshot.timersnapshot section in mypool.json
{
"include": ["mypool"],
"exclude": ["mypool/nobackup"],
"snapshot": {
"name_pattern": "snap-2006-01-02_15-04-05"
}
}Tip
name_pattern uses Go's reference time: Mon Jan 2 15:04:05 MST 2006. With ZFS delegation, no root required.
Set skip_empty_younger_than (e.g. "1h") to suppress new snapshots on idle datasets β a new snapshot is skipped when the most recent existing one is younger than this duration and the filesystem has not been written to since (written=0). Supports s, m, h, d, w, y suffixes.
Prunes snapshots according to configurable retention rules. Typically run hourly.
Systemd units
# /etc/systemd/system/zfsbackup-deleter.service
[Unit]
Description=ZFS Backup deleter
After=zfs.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfsbackup deleter --config /etc/zfsbackup/mypool.json --dry-run=false
# User=backupuser # uncomment if using ZFS delegation
# No [Install] section β this service is activated exclusively by the .timer unit# /etc/systemd/system/zfsbackup-deleter.timer
[Unit]
Description=ZFS Backup deleter β hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now zfsbackup-deleter.timerCaution
--dry-run defaults to true. The example service file passes --dry-run=false explicitly β remove that flag to run in dry-run mode for testing.
deleter section in mypool.json
{
"include": ["mypool"],
"exclude": ["mypool/nobackup"],
"deleter": {
"regex": ["snap-....-..-.._..-..-.." ],
"preserve_top_n": 5,
"preserve_newer_than": "3h",
"rules": [
{ "interval": "1h", "count": 48, "allow_holes": true },
{ "interval": "1d", "count": 30, "allow_holes": true },
{ "interval": "7d", "count": 12 },
{ "interval": "30d", "count": 12 }
]
}
}The above policy retains:
| Granularity | Coverage |
|---|---|
| π Always | The 5 newest snapshots + anything younger than 3 hours |
| β±οΈ Hourly | 1 per hour for the last 48 hours |
| π Daily | 1 per day for the last 30 days |
| ποΈ Weekly | 1 per week for the last 12 weeks |
| π Monthly | 1 per month for the last year |
Note
Rules are evaluated relative to the most recent snapshot, not the current time β so if snapshotting stops, old snapshots won't be pruned unexpectedly. allow_holes: true skips missing intervals instead of aborting. With ZFS delegation, no root required.
Sends incremental snapshot streams to a remote receiver. Typically run hourly.
Systemd units
# /etc/systemd/system/zfsbackup-sender.service
[Unit]
Description=ZFS Backup sender
After=network-online.target zfs.target
Wants=network-online.target
[Service]
Type=oneshot
TimeoutStartSec=0
ExecStart=/usr/local/bin/zfsbackup sender --config /etc/zfsbackup/mypool.json
# No [Install] section β this service is activated exclusively by the .timer unit# /etc/systemd/system/zfsbackup-sender.timer
[Unit]
Description=ZFS Backup sender β hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now zfsbackup-sender.timersender section in mypool.json
{
"include": ["mypool"],
"exclude": ["mypool/scratch"],
"sender": {
"name": "send_to_backupservers",
"snapshot_re": "snap-....-..-.._..-..-..",
"destinations": [
{
"receiver": "ssh username@primary -- ",
"compression": "zstd",
"placeholders": ["primary"]
},
{
"receiver": "ssh username@offsite -- ",
"compression": "zstd",
"raw_send": true,
"placeholders": ["offsite"]
}
]
}
}Each entry in destinations is processed independently for every included filesystem in a single zfsbackup sender run. Each destination maintains its own placeholder bookmarks, so the deleter can prune source snapshots while incremental chains to all destinations remain intact.
The receiver field is the command used to invoke the receiver β typically an SSH invocation (the example assumes ForceCommand is set for the SSH key on the remote side). For local backup to another pool:
"receiver": "zfsbackup receiver --config /etc/zfsbackup/receiver.json -- "Note
Placeholder bookmarks are created automatically after every successful send. A bookmark named #<snap>-dst<hash> is created on the source for each destination, derived from receiver, so source snapshots can be safely pruned by the deleter without breaking the incremental chain to any destination. No configuration is needed.
Set placeholders explicitly only when you need a human-readable name. Each destination must use a distinct suffix.
A placeholder suffix reserves every bookmark ending in -<suffix> on its filesystem. After creating the new checkpoint, zfsbackup removes older bookmarks with that suffix, including before-send bookmarks. Keep suffixes distinct across separate jobs and manually managed bookmarks as well.
Key sender-level options (apply to all destinations):
| Option | Description |
|---|---|
snapshot_re |
π Regex filter β only matching snapshot names are considered. Strongly recommended |
send_intermediate |
π Send all matching snapshots in order (default: newest only) |
include_properties |
π¦ Include ZFS properties in the stream (default: true) |
resumable |
β»οΈ Enable resumable transfers (default: true) |
Key per-destination options (set inside each destinations entry):
| Option | Description |
|---|---|
receiver |
π Command to invoke the receiver |
raw_send |
π Use zfs send -ecw β receiver stores ciphertext and never sees the encryption key |
compression: "zstd" |
β‘ Compress the stream in transit (sender and receiver must agree) |
compression_level |
ποΈ Override the compressor's default level (only meaningful with compression: "zstd") |
mbuffer_args |
π Smooth throughput over high-latency links via mbuffer |
placeholders |
π Override the auto-derived placeholder suffix |
sync_placeholders |
π Sync placeholder bookmarks to this receiver |
Deployed as an SSH forced command in authorized_keys on the backup host:
command="zfsbackup receiver --base_dataset=backuppool/accounts/myhost --config=/etc/zfsbackup/receiver.json",no-X11-forwarding,no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA... keyname
--base_dataset sets the root dataset under which received filesystems are stored β it overrides the config file value and can be set per authorized key.
receiver.json is optional. Use it to configure:
| Option | Description |
|---|---|
base_dataset |
Root destination dataset |
mbuffer_args |
Buffer on the receive side for smoother throughput |
enforce_local_properties |
ZFS properties to strip from the stream and set locally |
disable_mount |
Pass -u -o canmount=off to zfs receive so the dataset is never mounted now or on future zfs mount -a / boot (default: true) |
resumable |
β»οΈ Pass -s to zfs receive so interrupted transfers can be resumed (default: true; must match the sender's resumable) |
force_overwrite_datasets |
π₯ Destinations where zfs receive -F is permitted, allowing a full send to overwrite an existing dataset. Use only to recover from a broken incremental chain; remove each entry once recovery is complete |
{
"base_dataset": "tank/backups/myhost",
"disable_mount": true,
"enforce_local_properties": ["mountpoint"]
}If the incremental chain to a specific dataset is broken β e.g. the source was destroyed and recreated, so the destination's last snapshot has no matching GUID on the sender β list that dataset under force_overwrite_datasets. The next sender run transmits a full stream and the receiver applies zfs receive -F, replacing the destination.
{
"base_dataset": "tank/backups/myhost",
"force_overwrite_datasets": [
"tank/backups/myhost/mypool/data"
]
}Warning
Remove each entry as soon as the affected dataset has been re-sent successfully. Leaving it in place permanently disables the safety check that prevents accidental overwrites if the chain breaks again later.
Checks the age of the newest snapshot for each configured filesystem and exports Prometheus metrics via the Node Exporter textfile collector.
Systemd units
# /etc/systemd/system/zfsbackup-monitor.service
[Unit]
Description=ZFS Backup monitor
After=zfs.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfsbackup monitor --config /etc/zfsbackup/mypool.json# /etc/systemd/system/zfsbackup-monitor.timer
[Unit]
Description=ZFS Backup monitor β every 4 hours
[Timer]
OnCalendar=0/4:00:00
Persistent=true
[Install]
WantedBy=timers.targetsystemctl enable --now zfsbackup-monitor.timermonitor section in mypool.json
{
"include": ["mypool/important"],
"monitor": {
"prometheus_output": "/var/lib/node_exporter/textfile_collector/zfsbackup.prom"
}
}Write your own alerting rules against the exported LastSnapAge and LastSnapTimestamp metrics.
A filesystem with no snapshots exports LastSnapTimestamp=0 and LastSnapAge as the seconds since the Unix epoch, so ordinary freshness thresholds still alert. MonitorSuccess is 1 when all configured metrics were collected and 0 when any collection failed. Alert on MonitorSuccess == 0 as well: the monitor publishes the metrics it could collect and exits unsuccessfully if a pool or dataset query fails.
Note
No root required if zpool is in PATH. On many distributions it is root-only β check yours.
Building requires Go 1.25 or newer. Both hosts need OpenZFS 2.3 or newer: zfsbackup uses the JSON command output introduced in OpenZFS 2.3. Install zstd for compressed transfers and mbuffer when configuring buffering.
go install github.com/mikispag/zfsbackup/cmd/zfsbackup@latestOr build from source:
git clone https://github.com/mikispag/zfsbackup
cd zfsbackup
make build-
Create and delegate a ZFS filesystem for tests:
zfs create mypool/zfsbackuptestsuite zfs allow -ldu testsuiteuser \ bookmark,canmount,change-key,compression,create,destroy,diff,encryption,\
keyformat,keylocation,load-key,logbias,mount,mountpoint,promote,readonly,
receive,rename,rollback,send,snapshot,userprop
mypool/zfsbackuptestsuite
export DELEGATED_FS=mypool/zfsbackuptestsuite
2. Install [bats-core](https://github.com/bats-core/bats-core).
3. Run `make tests`.