A POSIX-shell fork of borgsnap for
automated ZFS + Borg backups: ZFS snapshots
feed local borg repositories, remote borg repositories (including
rsync.net,
Hetzner storagebox,
BorgBase), and/or plain zfs send targets,
all driven by one interval-based retention scheme, with restore-path
verification built in rather than assumed.
rsync.net, Hetzner, and BorgBase are mentioned purely as concrete, real-world examples this project has actually been tested against - not as an endorsement, sponsorship, or partnership. I receive no compensation, discount, affiliate commission, or other benefit of any kind from any of them, or from any other provider. Any SSH-reachable host running borg (or offering BorgBase-style access) works the same way; these just happen to be what was used for testing.
- Multiple destination types per dataset - local borg, remote borg
over SSH, BorgBase, and/or
zfssend(plain ZFS send/receive), all in oneREPOLIST, each with independent success/failure handling. BORG_VERIFY- periodicborg checkat a configurable depth (repo/archive/data) per retention interval, so a corrupted archive is caught proactively instead of during an actual restore.RESTORE_VERIFY- writes a canary file before each snapshot and confirms it survives a real extract from the freshly created archive, so "the backup completed" and "the backup is actually restorable" are no longer assumed to be the same thing.SNAPSHOT_TAG- an optional prefix on every ZFS snapshot label, so borgsnap_ng can run on the same dataset as another backup tool (including the original borgsnap) without a snapshot-name collision.- Resilience by design - one repo failing (network hiccup, wrong passphrase, not yet initialized) is logged clearly and skipped; it does not abort backups to every other configured repo, and does not prevent source-side ZFS retention from running.
MSG_LEVEL- configurable log verbosity (errors only, up through full debug), instead of an all-or-nothing firehose.- Capacity reporting - logs remaining free space on local repo filesystems and zfssend target pools after each run.
- systemd integration - per-dataset timer/service units and a mail wrapper that reports SUCCESS / SUCCESS (with warnings) / PARTIAL FAILURE / FAILURE, instead of relying on cron's mail-on-any-output.
- Interactive setup -
install.sh+setup-backup.shwalk through installing the tool and configuring each backup job, instead of hand-editing a config file from scratch.
The configuration file must include every option sample.conf
documents, even ones left empty - sample.conf is the authoritative,
inline-documented reference for every option; this README only
summarizes the ones most relevant to getting started.
If RECURSIVE=true (the second field of FS=), borgsnap_ng creates
recursive ZFS snapshots for that filesystem. Each child filesystem's
snapshot is mounted underneath the parent's snapshot mount, so borg
backs up the parent and all children in a single archive. Note: ZFS
snapshot deletion/retention only ever matches the exact configured
dataset, never its children, even when that dataset was snapshotted
recursively - see the warning next to FS= in sample.conf for a
gotcha this creates if you also back up one of those children
separately, as its own config entry.
This assumes borg 1.4 or later (for BORG_EXIT_CODES=modern support,
which several resilience features rely on to distinguish "not yet
initialized" from "genuinely broken" without guessing).
Finally, these things are probably obvious, but: make sure your local backups are on a different physical drive than the data you're backing up, and don't skip remote/offsite backups - a local-only backup isn't disaster-proofing your data.
git clone <this repo>
cd borgsnap_ng
sudo ./install.sh
sudo ./setup-backup.sh
install.sh copies borgsnap_ng to /usr/local/bin/borgsnap_ng (override
with --install-dir=PATH), and on request creates the dedicated borg
system user with its least-privilege sudo/tmpfiles setup, plus the
systemd unit templates. Run ./install.sh --help for all options, or
--dry-run to preview without changing anything. Safe to re-run on an
existing install (e.g. for a version update) - it only touches the
application files themselves, never any .conf/.key files already
sitting in the same directory.
setup-backup.sh then walks through configuring one actual backup job:
the user (if you skipped that in install.sh), the destination(s) - local
borg, remote borg (including SSH key setup if needed), zfssend, or a mix
- the source ZFS dataset, retention, verification, encryption
passphrase, ZFS delegation for that dataset, and finally registers and
enables a systemd timer for it. Run it again for each additional backup
job/dataset you want. It's fully interactive by design -
--dry-runpreviews without changing anything, but there's no non-interactive mode, since the whole point is walking through the interdependent choices rather than skipping straight to flags.
usage: borgsnap_ng.sh <command> <config_file> [<args>]
commands:
run Run backup lifecycle.
usage: borgsnap_ng.sh run <config_file>
snap Run backup for a specific snapshot.
usage: borgsnap_ng.sh snap <config_file> <snapshot-name>
tidy Unmount and remove today's snapshots/local backups.
usage: borgsnap_ng.sh tidy <config_file>
Added for test/dev purposes, may not work as intended!
Note: this unmounts every snapshot mounted by
borgsnap_ng, including other running instances.
If you used setup-backup.sh, a systemd timer already calls run for
you on schedule - mail_wrapper.sh (see ops/systemd/) wraps that call
to send a status email. Manual/ad-hoc runs use borgsnap_ng.sh directly.
For each configured filesystem, per run:
- Read the config file and encryption key file; validate the basics
(output directory exists, the executing user matches
LOCAL_BORG_USER, etc.). - Work out which retention interval (monthly/weekly/daily) qualifies today, and take a ZFS snapshot for it (recursively, if configured) - reusing an existing snapshot for today's label instead of failing if one's already there.
- For each configured repo/target in
REPOLIST:- Local or remote borg: initialize the repo if needed,
borg create,borg prune, and (ifBORG_VERIFYsays so)borg checkat the configured depth. - BorgBase: same, but repo existence/init state is detected via
borg list's own exit code, since BorgBase's restricted SSH access doesn't allow arbitrary shell commands. - zfssend: incremental
zfs send/zfs receiveto a local or bookmark- tracked target dataset instead of a borg archive. - If
RESTORE_VERIFYis enabled for today's interval, extract the freshly created archive (or check the zfssend target) and confirm the canary file written before the snapshot survived intact. - One repo's failure (unreachable, wrong passphrase, transient error) is logged clearly and that repo is skipped - it does not abort the other configured repos.
- Local or remote borg: initialize the repo if needed,
- Unmount the snapshot, then prune old ZFS snapshots for this dataset's
interval according to
RETENTIONPERIOD- independent of any single repo's prune outcome above.
That's it, once per configured filesystem.
If a run is interrupted or fails partway through, it's re-entrant: a
snapshot that already exists for today's label is reused, not treated as
a fatal error (though see BACKLOG.md for a couple of known edge cases
around zfssend bookmarks - a harmless, self-healing one on a same-day
retry, and a more consequential one if a source snapshot is ever
destroyed and recreated by hand while a zfssend target already has it).
The tidy command exists as a best-effort manual cleanup for test/dev
use, unmounting and removing today's snapshots/archives so a run can be
repeated - it predates the re-entrancy above and isn't normally needed
anymore.
borgsnap_ng doesn't help with restoring files, it just backs them up.
Restoration is done directly with borg (or straight from the ZFS
snapshot, for a simple accidental deletion). A backup that can't be
restored from is useless - RESTORE_VERIFY catches the most common way
that happens automatically, but you should still test a real restore
periodically.
Depending on why you need to restore:
- Local ZFS snapshot (the dataset's
.zfs/snapshot/directory) - the way to go for a simple accidental deletion with no hardware failure involved. - Local borg repository - if there's data loss on the source
filesystem but the local backup drive is still good, use
borg mountto browse and copy files out. - Remote borg repository (including BorgBase) - same idea as local, just a remote repo path.
- zfssend target - it's a normal ZFS dataset; browse it directly, or
zfs send/zfs receiveit back.
borgwrapper (in this repository) sets BORG_PASSPHRASE from a
borgsnap_ng config file's PASS, so you don't need to do that by hand
for ad-hoc borg commands:
$ sudo -u borg borgwrapper /path/to/myconfig.conf list /path/to/local/repo
monthly-20260701 Wed, 2026-07-01 03:00:12
weekly-20260706 Mon, 2026-07-06 03:00:08
daily-20260730 Thu, 2026-07-30 03:00:05
$ sudo -u borg borgwrapper /path/to/myconfig.conf mount /path/to/local/repo::daily-20260730 /mnt
$ ls /mnt/.zfs/snapshot/daily-20260730/
(the dataset's contents as of that snapshot)
$ sudo -u borg borgwrapper /path/to/myconfig.conf umount /mnt
Note that borgsnap_ng backs up directly from the ZFS snapshot via its
.zfs mount point, so the archive preserves that directory structure -
borg still deduplicates normally even though the leading path changes
with each snapshot name.
Without borgwrapper, the same thing works with a plain borg command
and BORG_PASSPHRASE exported by hand:
$ export BORG_PASSPHRASE=$(cat /path/to/myconfig.key)
$ borg list /path/to/local/repo
For a remote repo, add --remote-path=borg (or whatever REPOLIST
configured for that entry) and use the ssh://... path instead. See the
borg manpages for other restoration approaches, such as borg extract.
See BACKLOG.md for understood, deliberately deferred edge cases and possible future improvements.