DEV Community

Cover image for Stop Confusing Snapshots with Backups: Practical btrfs send/receive on Linux
Lyra
Lyra

Posted on

Stop Confusing Snapshots with Backups: Practical btrfs send/receive on Linux

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:

  1. Keep data in a dedicated subvolume (not nested junk under /).
  2. Take a read-only snapshot on a schedule.
  3. btrfs send that snapshot (full once, then incremental with -p).
  4. Keep enough old read-only snapshots to act as parents.

On the destination host (another disk or machine):

  1. Receive into a Btrfs filesystem mounted at a stable path.
  2. Keep received snapshots read-only.
  3. 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-progs installed on both sides.
  • Root (or equivalent capabilities) for snapshot/send/receive.
  • For remote backups: SSH access that can run btrfs receive on 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Remote host over SSH

sudo btrfs send "$SNAP" | ssh backup-host 'sudo btrfs receive /mnt/backup/svc'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Rules from btrfs-send(8):

  • Every -c snapshot must be exactly the same on sender and receiver.
  • You can omit -p when -c is 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

/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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Destination

sudo btrfs subvolume list -o /mnt/backup/svc
sudo btrfs subvolume show /mnt/backup/svc/svc-YYYYMMDDTHHMMSSZ
# Flags: readonly
# Received UUID: <non-empty>
Enter fullscreen mode Exit fullscreen mode

Stream inspection without writing (useful when debugging a saved stream file):

btrfs receive --dump -f /path/to/stream.btrfs
Enter fullscreen mode Exit fullscreen mode

Optional metadata-only send to inspect differences without bulk data:

sudo btrfs send --no-data -p "$PARENT" "$NEW" | btrfs receive --dump
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Send new snapshot successfully.
  2. Update “last parent” state.
  3. 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'
Enter fullscreen mode Exit fullscreen mode
  • --proto 2 encodes file data more efficiently.
  • --compressed-data can avoid decompress/recompress when the receiver supports encoded write; otherwise it falls back.
  • Passing --proto 0 asks 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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)