Local Btrfs snapshots feel like backups until the disk dies.
They are fast, space-efficient, and perfect for “undo that upgrade.” They are not a second copy of your data. Official Btrfs documentation is blunt about this: a snapshot and its origin share the same underlying blocks, so media damage or a wiped filesystem can take both with them.
btrfs send and btrfs receive close that gap. They turn a read-only snapshot into a stream you can pipe to another disk, another host, or an archive file—and after the first full transfer, incremental streams only carry what changed.
This post is a practical operator guide: layout, first full send, daily incrementals over SSH, parent tracking, retention, verification, and the footguns that break incremental chains.
What you are building
On the source host:
- Keep data in a dedicated subvolume (not nested junk under
/). - Take a read-only snapshot on a schedule.
-
btrfs sendthat snapshot (full once, then incremental with-p). - Keep enough old read-only snapshots to act as parents.
On the destination host (another disk or machine):
- Receive into a Btrfs filesystem mounted at a stable path.
- Keep received snapshots read-only.
- Prune old generations only after both sides agree on the new parent.
Result: point-in-time copies that survive source-disk failure, with bandwidth that looks more like rsync of deltas than “copy the world every night.”
Prerequisites
- Source and destination are Btrfs (receive cannot reconstruct a Btrfs subvolume on ext4/XFS).
-
btrfs-progsinstalled on both sides. - Root (or equivalent capabilities) for snapshot/send/receive.
- For remote backups: SSH access that can run
btrfs receiveon the destination. - Linux 6.0+ and btrfs-progs 6.0+ on both ends if you want send protocol v2 /
--compressed-data(optional; protocol v1 still works everywhere modern enough for normal ops).
Example layout used below:
Source:
/mnt/data # Btrfs mount (top-level or dedicated data FS)
/mnt/data/svc # writable subvolume with real data
/mnt/data/.snaps # directory for read-only snapshots
Destination:
/mnt/backup # separate Btrfs filesystem
/mnt/backup/svc # received snapshot tree
Create the source subvolume if you do not already have one:
# On source
sudo mkdir -p /mnt/data/.snaps
sudo btrfs subvolume create /mnt/data/svc
# put application data under /mnt/data/svc
On the backup host:
# On destination
sudo mkdir -p /mnt/backup/svc
# ensure /mnt/backup is the Btrfs top-level (or a writable parent subvolume)
findmnt -no FSTYPE,TARGET /mnt/backup
# expect: btrfs
Rule zero: send only from read-only snapshots
btrfs-send(8) requires every snapshot involved in a send to be read-only. A read-only mount of a writable subvolume is not enough—another mount could still write.
Always snapshot with -r:
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
SRC=/mnt/data/svc
SNAP=/mnt/data/.snaps/svc-${STAMP}
sudo btrfs subvolume snapshot -r "$SRC" "$SNAP"
sudo btrfs property get -ts "$SNAP" ro
# ro=true
That frozen snapshot is your consistent backup source. Applications that need true freeze consistency (busy databases) still need their own flush/quiesce story before the snapshot; the filesystem snapshot alone is crash-consistent, not app-transaction-aware.
First backup: full send
A full stream contains the entire snapshot. Pipe it locally or over SSH.
Same machine, second disk
sudo btrfs send "$SNAP" | sudo btrfs receive /mnt/backup/svc
Remote host over SSH
sudo btrfs send "$SNAP" | ssh backup-host 'sudo btrfs receive /mnt/backup/svc'
After success, destination has a new read-only subvolume named like the snapshot basename (svc-20260823T050000Z). Confirm:
# Destination
sudo btrfs subvolume list -o /mnt/backup/svc
sudo btrfs subvolume show /mnt/backup/svc/svc-20260823T050000Z
In show output, look for:
Flags: readonly- a non-empty
Received UUID -
Parent UUID/ UUID fields that identify the received generation
Received UUID is how Btrfs ties the destination copy back to the source snapshot identity for incremental use. Do not casually flip the destination to read-write; that path resets received_uuid (with force) and can break later incrementals.
Incremental backups: -p parent
Once both sides have the same parent snapshot content, send only the difference:
# Source: create today's RO snapshot
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
NEW=/mnt/data/.snaps/svc-${STAMP}
sudo btrfs subvolume snapshot -r /mnt/data/svc "$NEW"
# PARENT must exist on BOTH sides, still read-only, still matching
PARENT=/mnt/data/.snaps/svc-20260823T050000Z
sudo btrfs send -p "$PARENT" "$NEW" \
| ssh backup-host 'sudo btrfs receive /mnt/backup/svc'
What -p does (from the man page): generate an incremental stream from parent to subvol. Both must be read-only. The parent on the receiver must still be the unmodified received snapshot.
Clone sources (-c) when useful
If you keep multiple related snapshots and want the sender to reuse extents from more than one common ancestor, add clone sources:
sudo btrfs send -p "$PARENT" \
-c /mnt/data/.snaps/svc-other-common \
"$NEW" | ssh backup-host 'sudo btrfs receive /mnt/backup/svc'
Rules from btrfs-send(8):
- Every
-csnapshot must be exactly the same on sender and receiver. - You can omit
-pwhen-cis given; send will pick a suitable parent among clone sources. - Do not invent clone sources that only exist on one side.
For most homelab/service backups, a single linear parent chain (-p only) is simpler and safer.
A small, honest backup script
Save as /usr/local/sbin/btrfs-send-svc.sh on the source. Track the last successfully sent snapshot name in a state file.
#!/usr/bin/env bash
set -euo pipefail
SRC_SUBVOL="${SRC_SUBVOL:-/mnt/data/svc}"
SNAP_DIR="${SNAP_DIR:-/mnt/data/.snaps}"
PREFIX="${PREFIX:-svc}"
STATE_FILE="${STATE_FILE:-/var/lib/btrfs-send/${PREFIX}.last}"
DEST_SSH="${DEST_SSH:-backup-host}"
DEST_PATH="${DEST_PATH:-/mnt/backup/svc}"
KEEP_LOCAL="${KEEP_LOCAL:-14}"
umask 077
mkdir -p "$(dirname "$STATE_FILE")" "$SNAP_DIR"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
new_snap="${SNAP_DIR}/${PREFIX}-${stamp}"
btrfs subvolume snapshot -r "$SRC_SUBVOL" "$new_snap"
if [[ -f "$STATE_FILE" ]]; then
parent="$(cat "$STATE_FILE")"
if [[ ! -d "$parent" ]]; then
echo "Parent missing on source: $parent" >&2
exit 1
fi
# Incremental
btrfs send -p "$parent" "$new_snap" \
| ssh "$DEST_SSH" "btrfs receive ${DEST_PATH}"
else
# First full
btrfs send "$new_snap" \
| ssh "$DEST_SSH" "btrfs receive ${DEST_PATH}"
fi
printf '%s\n' "$new_snap" >"$STATE_FILE"
# Local retention: keep newest KEEP_LOCAL RO snaps for this prefix
mapfile -t snaps < <(ls -1d "${SNAP_DIR}/${PREFIX}-"* 2>/dev/null | sort)
if ((${#snaps[@]} > KEEP_LOCAL)); then
drop=$((${#snaps[@]} - KEEP_LOCAL))
for old in "${snaps[@]:0:drop}"; do
# Never delete the recorded parent
[[ "$old" == "$(cat "$STATE_FILE")" ]] && continue
btrfs subvolume delete "$old" || true
done
fi
echo "OK sent $new_snap"
Make it executable and dry-run the first full once by hand before automation:
sudo chmod 750 /usr/local/sbin/btrfs-send-svc.sh
sudo /usr/local/sbin/btrfs-send-svc.sh
Destination retention is separate: prune only snapshots older than your RPO window and never delete the parent the next incremental still needs. A simple approach is “keep N newest received names matching svc-*” after a successful send, run from the source over SSH, or as a second timer on the backup host.
systemd timer (no cron required)
/etc/systemd/system/btrfs-send-svc.service:
[Unit]
Description=Incremental btrfs send of /mnt/data/svc
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/btrfs-send-svc.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
/etc/systemd/system/btrfs-send-svc.timer:
[Unit]
Description=Daily btrfs send of /mnt/data/svc
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
Enable:
sudo systemctl daemon-reload
sudo systemctl enable --now btrfs-send-svc.timer
systemctl list-timers btrfs-send-svc.timer
journalctl -u btrfs-send-svc.service -n 50 --no-pager
Verification checklist
After a run, prove the chain instead of assuming it.
Source
sudo btrfs subvolume show /mnt/data/.snaps/svc-YYYYMMDDTHHMMSSZ
# Flags: readonly
cat /var/lib/btrfs-send/svc.last
Destination
sudo btrfs subvolume list -o /mnt/backup/svc
sudo btrfs subvolume show /mnt/backup/svc/svc-YYYYMMDDTHHMMSSZ
# Flags: readonly
# Received UUID: <non-empty>
Stream inspection without writing (useful when debugging a saved stream file):
btrfs receive --dump -f /path/to/stream.btrfs
Optional metadata-only send to inspect differences without bulk data:
sudo btrfs send --no-data -p "$PARENT" "$NEW" | btrfs receive --dump
--no-data is for inspection, not backup restore.
Restore drill (do this before you need it):
# On destination, snapshot the received RO snap to a writable restore target
sudo btrfs subvolume snapshot \
/mnt/backup/svc/svc-YYYYMMDDTHHMMSSZ \
/mnt/restore/svc-restored
That writable clone is for recovery testing. Leave the original received snapshot read-only so future incrementals still have a clean parent.
Footguns that break incremental chains
1. Nested subvolumes are not recursive
Snapshotting is not recursive. Nested child subvolumes appear as empty stubs (inode 2) inside a parent snapshot and are not sent. If /var/lib/postgres is its own subvolume under a parent you snapshot, Postgres data will not ride along.
Fix: send each data subvolume on its own schedule, or flatten the layout so each backup unit is one subvolume.
2. Making received snapshots read-write
Incremental send/receive assumes matching, unmodified snapshots on both sides. Flipping a received subvolume to RW clears received_uuid (with force on modern btrfs-progs) and can poison the next -p / -c use. Prefer cloning a new writable snapshot for restores.
3. Deleting the only common parent
If you prune the parent on either side before the next incremental, send fails or you are forced into another full. Order of operations:
- Send new snapshot successfully.
- Update “last parent” state.
- Then prune older generations, keeping the new parent on both sides.
4. Default subvolume / mount path surprises on receive
btrfs-receive(8) fails when the receiving subvolume already exists, when a previously received subvolume was changed, or when the destination filesystem is not mounted at the top-level in a way receive can resolve. If you are in a weird mount namespace or chroot, pass -m /path/to/btrfs-root.
5. Untrusted streams
The receive man page warns that crafted streams can create dangerous reflinks on the destination filesystem. Only receive streams from hosts and paths you trust, and protect streams in transit (SSH is the easy default).
6. “Snapshot finished instantly, so backup is done”
Snapshot creation is cheap metadata. The send is the real work. Watch the oneshot unit, not just the snapshot command.
Optional: protocol v2 and compressed send
On Linux 6.0+ with recent btrfs-progs:
sudo btrfs send --proto 2 --compressed-data -p "$PARENT" "$NEW" \
| ssh backup-host 'btrfs receive /mnt/backup/svc'
-
--proto 2encodes file data more efficiently. -
--compressed-datacan avoid decompress/recompress when the receiver supports encoded write; otherwise it falls back. - Passing
--proto 0asks for the highest version the running kernel supports.
Stick to defaults until both ends are known-good on 6.0+, then bench on a large compressible dataset.
How this differs from nearby tools
| Tooling | Job |
|---|---|
| Local RO snapshots / Snapper | Fast local rollback, not off-disk survival |
btrfs scrub |
Checksum verification on one filesystem |
rsync / rclone
|
File-level sync; great, but no native Btrfs extent clone semantics |
btrfs send/receive |
Subvolume-level full + incremental replication to another Btrfs |
| Rest dumps (pg_dump, etc.) | Application-consistent logical backups—still complementary |
Use send/receive for filesystem-level disaster copies. Keep app-aware dumps where transactional restore matters more than block identity.
Minimal lab (two loop files)
If you want a throwaway practice FS without touching production disks:
fallocate -l 2G /var/tmp/btrfs-src.img
fallocate -l 2G /var/tmp/btrfs-dst.img
sudo losetup -fP /var/tmp/btrfs-src.img
sudo losetup -fP /var/tmp/btrfs-dst.img
# note loop devices from losetup -a
sudo mkfs.btrfs -f /dev/loopX
sudo mkfs.btrfs -f /dev/loopY
sudo mkdir -p /mnt/lab-src /mnt/lab-dst
sudo mount /dev/loopX /mnt/lab-src
sudo mount /dev/loopY /mnt/lab-dst
sudo btrfs subvolume create /mnt/lab-src/data
echo hello | sudo tee /mnt/lab-src/data/file
sudo btrfs subvolume snapshot -r /mnt/lab-src/data /mnt/lab-src/data-ro1
sudo btrfs send /mnt/lab-src/data-ro1 | sudo btrfs receive /mnt/lab-dst
echo world | sudo tee -a /mnt/lab-src/data/file
sudo btrfs subvolume snapshot -r /mnt/lab-src/data /mnt/lab-src/data-ro2
sudo btrfs send -p /mnt/lab-src/data-ro1 /mnt/lab-src/data-ro2 \
| sudo btrfs receive /mnt/lab-dst
sudo btrfs subvolume list /mnt/lab-dst
Clean up loops/mounts when finished.
Wrap-up
Btrfs snapshots are an outstanding local undo button. btrfs send / btrfs receive are how those snapshots become backups: full once, incremental forever after, preferably to another disk or host, always from read-only parents, with retention that never murders the next common ancestor.
Wire a oneshot + timer, keep received copies read-only, and schedule a restore drill. That is the difference between “we have snapshots” and “we can get the data back.”
References
- Btrfs Send/receive overview
- btrfs-send(8)
- btrfs-receive(8)
- btrfs-subvolume(8) — especially subvolume flags, nested snapshot barriers, and “a snapshot is not a backup”
- Subvolumes documentation
Top comments (0)