Btrfs “Recursive” Snapshots

Btrfs “Recursive” Snapshots
Photo by Matias Megapixel / Unsplash

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 like borg-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.state to 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.lock so 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) and findmnt available. The scripts check for them and bail if missing.
  • A mount point to stage the mirrored snapshot tree: default is /mnt/borgbackup and must be empty before each run.
  • Enough free space for snapshots (they’re COW, but still).
  • Optional: logger for 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/borgbackup is not empty.
  • Discovers Btrfs mounts with findmnt, then (for filesystem roots) enumerates subvolumes with btrfs 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 them ro,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 from SNAPSHOT_PREFIX and a timestamp.
  • Mount staging base: /mnt/borgbackup (constant in both scripts).
  • Syslog tag via BORG_SYSLOG_TAG and console logging via LOG_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.

Subscribe to Schmax' Blog

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe