The model
Btrfs snapshots are copy-on-write — a snapshot is essentially free to create, and its disk cost is only the data that diverges from the source afterward. btrfs send -p PARENT SNAPSHOT reads the COW metadata between two snapshots and emits a binary stream of operations: "add file X with contents Y," "truncate file Z to N bytes," "remove file W." On the receiver, btrfs receive applies those operations into a target subvolume. Because both sides are Btrfs and both sides keep the prior snapshot, sender and receiver stay in sync over arbitrarily many incremental steps.
Initial seed
Snapshot the source subvolume read-only:
sudo btrfs subvolume snapshot -r /data /data/.snapshots/2025-10-01
Send the full snapshot to a Btrfs filesystem on a USB drive or remote box. Locally:
sudo btrfs send /data/.snapshots/2025-10-01 \
| sudo btrfs receive /mnt/backup/data/
To another host over SSH:
sudo btrfs send /data/.snapshots/2025-10-01 \
| ssh root@backup-box "btrfs receive /mnt/backup/data/"
The destination ends with /mnt/backup/data/2025-10-01 as a read-only subvolume that's bit-identical to the source snapshot.
Incremental sends
From now on, send only the delta against the prior snapshot:
# Next day: take a new snapshot
sudo btrfs subvolume snapshot -r /data /data/.snapshots/2025-10-02
# Send the delta against the prior one
sudo btrfs send -p /data/.snapshots/2025-10-01 /data/.snapshots/2025-10-02 \
| ssh root@backup-box "btrfs receive /mnt/backup/data/"
The -p PARENT flag tells btrfs send "compute the diff relative to this snapshot." The receiver must have that parent snapshot already, otherwise it refuses to apply the stream.
After the receive, /mnt/backup/data/2025-10-02 exists alongside /mnt/backup/data/2025-10-01 — both are independent, point-in-time-accurate snapshots of the source.
Pruning
Both sides accumulate snapshots; both need pruning. The rule: keep at least one snapshot on both sides that can serve as the parent for the next incremental send. Otherwise, the chain breaks and the next send fails.
# Delete an old snapshot once a newer one is the new parent
sudo btrfs subvolume delete /data/.snapshots/2025-09-30
ssh root@backup-box "btrfs subvolume delete /mnt/backup/data/2025-09-30"
btrbk: the automation layer
Doing this by hand is fine for a one-off. For a fleet, btrbk manages snapshots, prunes them, and runs incremental sends to local or remote Btrfs targets via a single config file:
# /etc/btrbk/btrbk.conf
timestamp_format long
snapshot_preserve_min 2d
snapshot_preserve 14d 4w 6m
target_preserve_min no
target_preserve 14d 8w 12m
volume /data
subvolume @home
snapshot_create onchange
target ssh://backup-box:/mnt/backup/data/
sudo btrbk dryrun
sudo btrbk run
Drop the btrbk run into a systemd timer and the entire backup loop runs nightly without manual snapshot bookkeeping.
SSH key for unattended sends
btrfs send/receive over SSH needs btrfs root on the remote. The minimum-privilege pattern: a dedicated SSH key whose authorized_keys entry forces a single command:
# on the backup host, /root/.ssh/authorized_keys
command="/usr/bin/sudo /usr/bin/btrfs receive /mnt/backup/data/",no-port-forwarding,no-x11-forwarding ssh-ed25519 AAAA... amir@source
The key on the source then can only run btrfs receive at one path; nothing else. Same idea via btrbk's built-in ssh_compression + ssh_cipher options for speed tuning.
Compression on the wire
Btrfs subvolumes that use compress=zstd already store data compressed; the stream emitted by btrfs send is the raw on-disk format, so it's mostly pre-compressed. Wrapping the pipe in zstd still helps when the source is uncompressed:
sudo btrfs send -p <parent> <snap> \
| zstd -3 \
| ssh root@backup-box "zstd -d | btrfs receive /mnt/backup/data/"
Encryption
Btrfs send streams are plaintext. Two encryption options:
- Encrypt the destination filesystem with LUKS — backups land on a LUKS-protected disk. Doesn't help if the wire is on the public internet but covers physical-disk theft.
- Encrypt the stream in transit with
ageorgpg— the destination needs to decrypt to receive, so it doesn't help for "the backup host shouldn't be able to read the data," but it does protect against a compromised intermediate.
For "the backup target should never see plaintext," restic is the better tool — client-side encryption is its default. btrfs send/receive is best when sender and receiver are in the same trust domain.
Comparison to rsync
- rsync walks both file trees, computes checksums, transfers the differences. Cost scales with total file count (not changed-data size), so backing up a 10 TB filesystem takes hours on metadata alone, even if nothing changed.
- btrfs send -p reads only the COW metadata between two snapshots; cost scales with changed-data size. A 10 TB filesystem with 100 MB of changes takes seconds of metadata work plus the time to send 100 MB.
The trade-off: rsync works between any two filesystems; btrfs send requires Btrfs on both ends. For data that has to leave Btrfs (cloud object storage, ext4 destinations, cross-platform sync), rsync or restic are still the right tools.
Restoring
The destination subvolumes are read-only. To restore data from one:
# Mount the destination filesystem
sudo mount /dev/sdc1 /mnt/backup
# A snapshot is a regular directory tree
cp -a /mnt/backup/data/2025-10-15/some/file /home/amir/
# Or boot from it: snapshot it again, this time read-write
sudo btrfs subvolume snapshot /mnt/backup/data/2025-10-15 /mnt/backup/data/restore-2025-10-15
For a full bare-metal restore, send the most recent backup back to a fresh Btrfs filesystem on the new disk, then adjust fstab and grub for the new UUIDs.