Btrfs “Recursive” Snapshots
An easy non-atomic solution to create recursive snapshots of Btrfs subvolumes.
I run a headless Debian 12 home server with OpenMediaVault (OMV) acting as NAS on a 16 TB Seagate Exos. I’m happy with Btrfs, but it doesn’t offer a native recursive snapshot like ZFS does. When you have multiple subvolumes (root, /home, Docker stuff, OMV shared folders, etc.), creating snapshots one-by-one is tedious—and you also want them mounted in a layout that mirrors your live filesystem for clean, point-in-time backups.
This post shows the approach I use: two small Bash scripts that (1) discover your Btrfs subvolumes dynamically, snapshot all of them, and mount those snapshots into a mirrored tree, and (2) unmount and clean them up afterwards. I run them as Borg pre/post hooks so backups work from a stable read-only view.
Note: Btrfs doesn’t have a built-in “recursive snapshot” flag; snapshots are per-subvolume. The scripts below make it behave recursively by walking mounts/subvolumes and snapshotting them in sequence. That’s not atomic like ZFS’s -r, but it’s more than good enough for my use case.What the scripts do
- Auto-discover all relevant Btrfs mounts and (for filesystem roots) list subvolumes that OMV created for shared folders. No hard-coded list required.
- Create read-only snapshots for each mount/subvolume into a timestamped run directory (default hidden dir:
.borg-backup-snapshots). The snapshot tag looks likeborg-pre-YYYYMMDDTHHMMSSZ. - Bind-mount the snapshots into a single mirror tree at
/mnt/borgbackup/root..., so the layout matches your live system paths as closely as possible. Everything is then remounted read-only. - Track state in
/run/borgbackup/snapshots-homeserver.stateto know exactly what to unmount and delete later. - Clean up safely: a matching “remove” script unmounts in reverse order, deletes only the snapshots it created, and prunes empty dirs; it also understands a legacy state format.
- Concurrency-safe with a lock file at
/run/lock/borg-btrfs.lockso a second backup can’t trample the first.
My setup (so you can judge compatibility)
- Debian 12 (headless)
- OMV managing the NAS
- Btrfs subvolumes for: root filesystem,
/home, Docker container data/volumes, plus OMV shared folders on a Btrfs data disk - BorgBackup handles the actual backup, using the scripts as pre/post hooks
These scripts were written for my environment and tested only there. If your layout differs, read the code and test first—don’t drop it straight into cron on prod. I won’t pretend this is a universal solution, but it might save you a few evenings.
Requirements & assumptions
- Btrfs tools (
btrfs) andfindmntavailable. The scripts check for them and bail if missing. - A mount point to stage the mirrored snapshot tree: default is
/mnt/borgbackupand must be empty before each run. - Enough free space for snapshots (they’re COW, but still).
- Optional:
loggerfor syslog entries; consoles logs can be toggled.
The moving parts (at a glance)
1) Create & mount snapshots (pre-hook)
Script: btrfs-create-borg-snapshots.sh
- Refuses to run if a previous state file exists or
/mnt/borgbackupis not empty. - Discovers Btrfs mounts with
findmnt, then (for filesystem roots) enumerates subvolumes withbtrfs subvolume list, skipping the snapshot dir itself. - Bind-mounts each snapshot under
/mnt/borgbackup/root/...to mirror your live mount layout or uses a safe fallback path if needed. Then remounts themro,bind. - Writes a state file listing original mounts, snapshot paths, and mount targets so the post-hook can cleanly undo everything.
Creates read-only snapshots in a per-device run dir like:
<mount>/.borg-backup-snapshots/borg-pre-<UTC-TIMESTAMP>/<sanitized-name>
The snapshot root name and tag are configurable; see “Tuning” below.
You can find the Script in this gist.
2) Unmount & delete snapshots (post-hook)
Script: btrfs-remove-borg-snapshots.sh
- Uses the state file to unmount in reverse order, prunes directories, and deletes only snapshots created by the pre-hook (supports the new format and a legacy format).
- If something can’t be removed, it leaves a trimmed state so you can retry without guesswork.
You can find the Script in this gist.
Tuning & environment variables
A few knobs you may want to turn:
SNAPSHOT_DIR_NAME(env:BORG_SNAPSHOT_DIR_NAME, default:.borg-backup-snapshots) — where per-device run dirs live.- Snapshot tag/prefix:
borg-pre-<UTC>; automatically set fromSNAPSHOT_PREFIXand a timestamp. - Mount staging base:
/mnt/borgbackup(constant in both scripts). - Syslog tag via
BORG_SYSLOG_TAGand console logging viaLOG_TO_CONSOLE=0|1. - Safety filters: paths inside the snapshot dir or the mount base are ignored to avoid recursion/loops.
Limitations (and a bit of honesty)
- This isn’t atomic across all subvolumes. Snapshots happen one after another. If something mutates between subvolumes, you’ll capture slightly different moments. For most homelab uses, it’s fine; if you need truly atomic recursive snaps, ZFS still wins.
- Because the tree is discovered dynamically, changes in your mount/subvolume topology are picked up automatically—great for OMV setups where shared folders come and go—but always test after big layout changes.
- I only tested on my system. If your paths or OMV layout differ, read the scripts first and run them in a safe environment.
Files
- Create snapshots & mount:
btrfs-create-borg-snapshots.sh(lock file, discovery, ro snapshots, mirrored mounts, state file). - Remove snapshots:
btrfs-remove-borg-snapshots.sh(reverse unmount, delete, legacy support, resilient state handling).
You can get the files from this gist.
If this saves you some time — awesome. If it breaks your exotic setup — please don’t be mad at me; it wasn’t built for the outside world. 😉 Read the code, tweak the variables, and test with care.