Skip to content

Latest commit

Β 

History

32 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ—„οΈ zfsbackup

CI

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.


🧩 Modules

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.


πŸš€ Run (unified)

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.json

Pass --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.target
systemctl enable --now zfsbackup.timer

mypool.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.


πŸ“Έ Snapshot

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.target
systemctl enable --now zfsbackup-snapshot.timer

snapshot 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.


πŸ—‘οΈ Deleter

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.target
systemctl enable --now zfsbackup-deleter.timer

Caution

--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.


πŸ“€ Sender

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.target
systemctl enable --now zfsbackup-sender.timer

sender 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

πŸ“₯ 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"]
}

Recovering a broken incremental chain

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.


πŸ“Š Monitor

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.target
systemctl enable --now zfsbackup-monitor.timer

monitor 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.


πŸ“₯ Installation

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@latest

Or build from source:

git clone https://github.com/mikispag/zfsbackup
cd zfsbackup
make build

πŸ§ͺ Running the test suite

  1. 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`.

About

Automated ZFS snapshot, replication, and backup tool with Prometheus monitoring.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages