VPS Snaps

How to back up with ZFS snapshots and zfs send/receive

A ZFS snapshot is an instant, read-only copy of a dataset, but it lives on the same pool as the data, so it only becomes a backup once it is copied to another machine. Take one with zfs snapshot tank/web@auto-2026-10-04_0200, copy it with zfs send tank/web@auto-2026-10-04_0200 | ssh backup-host zfs receive backup/servers/web, and from then on send only the changes with zfs send -i.

9 min readUpdated Checked against official documentation

Take a snapshot

Terminal
sudo zfs snapshot tank/web@auto-2026-10-04_0200

The name is the dataset, @, and a name you choose; the whole thing can be up to 255 characters. OpenZFS creates it atomically: it holds every change from system calls that completed before that instant. It uses no space at first and grows as the live data moves away from it. -r snapshots a dataset and all its children at the same moment:

Terminal
sudo zfs snapshot -r tank@auto-2026-10-04_0200

Give automated snapshots a prefix and a sortable time, such as auto- plus date +%Y-%m-%d_%H%M, so scripts touch only their own and leave @before-upgrade alone. Snapshots are crash-consistent. For MySQL, hold FLUSH TABLES WITH READ LOCK while taking one, as the LVM snapshot guide shows with system (here system zfs snapshot ...). For PostgreSQL, if the data directory and WAL are separate datasets, snapshot their parent with -r so both are taken at the same instant.

List snapshots and copy files out of them

Terminal
zfs list -t snapshot -o name,used,referenced,creation -s creation -r tank/web

-r includes child datasets and -s creation sorts oldest first. USED is the space only that snapshot holds, freed if you destroy it; shared space is in nobody's USED, so check the total with zfs list -o name,usedbysnapshots tank/web.

Every filesystem has a .zfs/snapshot directory at its root, one read-only directory per snapshot, mounted on demand. With the default snapdir=hidden it is left out of listings, but the path works, which makes this the quickest restore there is:

Terminal
sudo cp -a /tank/web/.zfs/snapshot/auto-2026-10-04_0200/html/wp-config.php /tank/web/html/

zfs set snapdir=visible tank/web shows it in listings. OpenZFS 2.3 and later also accept snapdir=disabled.

Roll a dataset back

Terminal
sudo zfs rollback tank/web@auto-2026-10-04_0200

This discards every change since the snapshot, and by default works only with the newest one. -r goes further back by destroying every newer snapshot and bookmark; -R also destroys their clones.

Those snapshots are gone for good. An incremental can only be received on top of the snapshot it was made from, so if the backup host's newest snapshot was among them, the next incremental fails.

Why snapshots on the same pool are not a backup

Snapshots share blocks with the live data. They cover deleted files and bad upgrades, not anything that takes the pool: more failed disks than its redundancy covers, a damaged pool, a deleted server, or root running zfs destroy -r. A backup is a copy on another machine, ideally elsewhere (the 3-2-1 rule).

On Btrfs the same ideas apply with btrfs send and btrfs receive: see how to back up with Btrfs snapshots.

Prepare the backup host

On the backup host, as root, with a pool called backup: create a parent dataset that is never mounted, and let a user zfsrecv receive into it.

Terminal
zfs create -o mountpoint=none backup/servers
Terminal
zfs allow -u zfsrecv create,mount,receive backup/servers

Received datasets inherit mountpoint=none, so nothing mounts or writes to them. zfs receive needs create and mount as well as receive; on Linux, actual mounting can't be delegated, so receive with -u. Give zfsrecv the server's SSH public key.

Pulling is safer than pushing: the backup host logs in to the server and runs zfs send there, so a compromised server holds no login to its backups. See protecting backups from ransomware.

Send the first full copy

On the server, as root:

Terminal
zfs send tank/web@auto-2026-10-04_0200 | ssh [email protected] zfs receive -s -u backup/servers/web
PartWhat it does
zfs send tank/web@...Writes a full stream of the snapshot to standard output.
zfs receive backup/servers/webCreates that dataset from the stream, with a snapshot of the same name.
-uDoes not mount it.
-sKeeps a broken transfer's progress so it can resume. Needs the extensible_dataset pool feature.

The first send copies every block. zfs send -c keeps compressed blocks compressed (the receiver needs the same compression features) and -v reports progress each second. Check it with zfs list -t snapshot -r backup/servers/web on the backup host.

Send only the changes: -i and -I

Terminal
zfs snapshot tank/web@auto-2026-10-05_0200
Terminal
zfs send -i @auto-2026-10-04_0200 tank/web@auto-2026-10-05_0200 | ssh [email protected] zfs receive -s -u backup/servers/web

-i sends only the blocks changed between the two snapshots; the source can be written as @name on the same dataset. The target's newest snapshot must be that source, or the receive is refused. -i delivers only the end state; -I @auto-2026-10-01_0200 also sends every snapshot in between. If the target changed after its newest snapshot, zfs receive -F rolls it back first.

zfs send -R replicates a dataset with its children, snapshots and properties. Received with -F, an incremental replication stream destroys snapshots on the backup host that the server no longer has, so the backup keeps no more history than the server.

Resume an interrupted transfer

With -s, a broken transfer leaves a token in the target's receive_resume_token property:

Terminal
TOKEN=$(ssh [email protected] zfs get -H -o value receive_resume_token backup/servers/web)

If it is a long string rather than -, zfs send -t carries on where the stream stopped:

Terminal
zfs send -t "$TOKEN" | ssh [email protected] zfs receive -s -u backup/servers/web

zfs receive -A backup/servers/web discards the partial state instead. Resume or abort before sending anything new to that dataset.

Encrypted datasets: raw sends

For a dataset with ZFS native encryption, a plain zfs send decrypts on the way out. -w (--raw) sends the blocks as stored, still encrypted:

Terminal
zfs send -w -i @auto-2026-10-04_0200 tank/web@auto-2026-10-05_0200 | ssh [email protected] zfs receive -s -u backup/servers/web
  • The backup host never needs the key and can't read or alter the data undetected. zpool scrub backup still verifies every checksum there.
  • Stay with raw or non-raw for a dataset: a raw incremental fails on top of a non-raw receive.
  • Keep the key away from both machines. To restore, send back raw, then zfs mount -l asks for the key and mounts.

Send to a file or object storage

A stream can be stored as a file, for example in an S3 bucket:

Terminal
zfs send -w tank/web@auto-2026-10-04_0200 | aws s3 cp - s3://acme-server-backups/web-01/tank-web@auto-2026-10-04_0200.zfs

Above 50 GB, aws s3 cp needs --expected-size in bytes; zfs send -nv with the same arguments shows what would be sent. Know the trade-offs:

  • A stream is not an archive: no listing, no single-file restore. Any restore is a zfs receive of the whole stream into a pool with room for it.
  • Incrementals form a chain. Restoring needs the full stream and every incremental after it, in order; lose one and the rest are useless.
  • Damage is detected, not repaired: a receive stops at a checksum error. zstream dump validates a stream's checksums, so check uploads with aws s3 cp s3://acme-server-backups/web-01/tank-web@auto-2026-10-04_0200.zfs - | zstream dump.
  • OpenZFS commits to the stream format, so future versions can receive today's streams.

Prune old snapshots

Snapshots hold on to deleted blocks, so keep only what you need. zfs destroy takes an inclusive range joined by %, with only the short name after it. Dry-run it with -nv first:

Terminal
sudo zfs destroy -nv tank/web@auto-2026-09-01_0200%auto-2026-09-14_0200

Then run it without -n. A blank end means the oldest or newest, so tank/web@% deletes every snapshot of the dataset. It fails on a snapshot that has a hold (zfs hold) or a clone.

Read the -nv output every time, and never drop the @: zfs destroy tank/web targets the dataset itself.

To keep the newest 14 automated snapshots and destroy the rest:

Terminal
zfs list -H -t snapshot -o name -s creation -r tank/web | grep '^tank/web@auto-' | head -n -14 | xargs -r -n 1 zfs destroy

-H drops the header, head -n -14 prints all but the last 14 lines and xargs -r runs nothing on an empty list. Prune the backup host the same way with a larger count, never 0: its newest snapshot is the next incremental's base.

A bookmark keeps incrementals possible after the server deleted the source snapshot: zfs bookmark tank/web@auto-2026-10-05_0200 tank/web#auto-2026-10-05_0200, then zfs send -i '#auto-2026-10-05_0200' tank/web@auto-2026-10-06_0200.

Automate it

Run as root on the server, this resumes a broken transfer, takes a snapshot, sends a full stream the first time and incrementals after, then prunes the server's snapshots:

/usr/local/sbin/zfs-backup-push
#!/usr/bin/env bash
# Snapshot tank/web and send it to the backup host, incrementally after the first run.
set -euo pipefail

DS=tank/web
[email protected]
TARGET=backup/servers/web
KEEP=14                                   # auto- snapshots kept on this server
NEW="auto-$(date +%Y-%m-%d_%H%M)"

# 1. Finish a transfer that was interrupted last time.
TOKEN=$(ssh "$REMOTE" zfs get -H -o value receive_resume_token "$TARGET" 2>/dev/null || true)
if [ -n "$TOKEN" ] && [ "$TOKEN" != "-" ]; then
  zfs send -t "$TOKEN" | ssh "$REMOTE" zfs receive -s -u "$TARGET"
fi

zfs snapshot "$DS@$NEW"

# 2. Incremental from the newest snapshot the backup host has, or a full send the first time.
if ssh "$REMOTE" zfs list -H -o name "$TARGET" >/dev/null 2>&1; then
  LAST=$(ssh "$REMOTE" zfs list -H -t snapshot -o name -s creation -r "$TARGET" \
    | grep "^$TARGET@auto-" | tail -n 1 | cut -d@ -f2 || true)
  if [ -z "$LAST" ]; then
    echo "$TARGET has no auto- snapshot to send from" >&2
    exit 1
  fi
  zfs send -i "@$LAST" "$DS@$NEW" | ssh "$REMOTE" zfs receive -s -u "$TARGET"
else
  zfs send "$DS@$NEW" | ssh "$REMOTE" zfs receive -s -u "$TARGET"
fi

# 3. Keep the newest $KEEP auto- snapshots here. The backup host prunes its own.
zfs list -H -t snapshot -o name -s creation -r "$DS" \
  | grep "^$DS@auto-" | head -n "-$KEEP" | xargs -r -n 1 zfs destroy
  • Step 2 sends from the backup host's newest snapshot, so a failed night heals on the next run, as long as the server still has that snapshot (14 here; use bookmarks for more slack).
  • If the remote side can't find zfs, a non-root SSH session there may lack /usr/sbin in PATH; use the full path (command -v zfs).
/etc/cron.d/zfs-backup-push
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# m  h  dom mon dow  user  command
0  2  *   *   *    root  flock -n /run/lock/zfs-backup-push.lock /usr/local/sbin/zfs-backup-push >> /var/log/zfs-backup-push.log 2>&1

Restore from the backup host

For a few files, clone the snapshot on the backup host (as root), copy what you need, then zfs destroy backup/servers/web-restore:

Terminal
zfs clone -o mountpoint=/mnt/web-restore backup/servers/web@auto-2026-10-04_0200 backup/servers/web-restore

A clone is near-instant and initially takes no space; its snapshot can't be destroyed while it exists. To rebuild the dataset, send it back under a new name, check it, then swap names with zfs rename:

Terminal
zfs send backup/servers/web@auto-2026-10-04_0200 | ssh [email protected] zfs receive -u tank/web-restored

Practice this before you need it (testing a restore), and scrub the backup pool regularly to catch silent damage.

Frequently asked questions

Is a ZFS snapshot a backup?
Not on its own: it shares the pool with the live data. It becomes a backup once zfs send has copied it to another machine.
How much space does a ZFS snapshot use?
None when taken. It grows as blocks in the live dataset are changed or deleted, because it keeps the old versions.
What is the difference between zfs send -i and -I?
-i sends the difference between two snapshots in one step. -I also sends every snapshot between them.
Can I restore one file from a zfs send stream?
Not directly. Receive the stream into a pool with zfs receive, then copy the file out.

How this was checked

Commands, limits and prices were checked against these official pages, on October 4, 2026: