ZFS send/receive backups to a storage VPS
Replicate ZFS datasets to a storage VPS with zfs send and receive: full and incremental streams over SSH, compression, resumable transfers and a systemd timer.
What ZFS send and receive give you
ZFS send and receive copy a dataset to a storage VPS as a stream of blocks. zfs send turns a snapshot into a byte stream, SSH carries it, and zfs receive on the VPS turns it back into the same dataset holding the same snapshot. The first run copies everything. Every run after that sends only the blocks that changed since the newest snapshot both sides share, so a nightly push of a multi-terabyte dataset usually finishes in minutes.
The receiving side in this guide is Linux OpenZFS on a storage VPS. Ubuntu 24.04 ships OpenZFS 2.2.2 (package zfsutils-linux 2.2.2-0ubuntu9) and Ubuntu 26.04 ships OpenZFS 2.4.1, as of September 2026. Every flag below exists in both, checked against the OpenZFS zfs-send(8) and zfs-receive(8) manual pages. The sender can be any OpenZFS system: an Ubuntu box or a FreeBSD NAS such as TrueNAS CORE. The stream format is the same across ZFS on FreeBSD and on Linux, so a FreeBSD sender and a Linux receiver work together. SSD Nodes does not offer FreeBSD images, which is why the receiver here is Linux.
What you get at the end: a read-only replica of your dataset on the VPS with its full snapshot history, a transfer that survives a dropped connection, a repair procedure for the incremental that refuses to go through, and a systemd timer that runs the push every night. What you do not get: encryption at rest on the VPS unless the sender's dataset is already encrypted, and deduplication against other hosts. The section on restic and borg near the end covers when those tools fit better.
Prepare the storage VPS as a receiver
Install the OpenZFS userland on the VPS. Ubuntu builds the ZFS kernel module into its kernel package, so zfsutils-linux is the only package you need.
sudo apt update && sudo apt install -y zfsutils-linux
sudo modprobe zfs
zfs versionzfs version prints two lines, the userland and the kernel module. On 24.04 both start with zfs-2.2.2. If modprobe zfs fails with Module zfs not found, the running kernel has no ZFS module, which on Ubuntu means a custom or third-party kernel. Boot the stock linux-generic kernel instead.
Create the pool on a disk or partition that holds nothing else. lsblk shows what you have. Use the /dev/disk/by-id/ name rather than /dev/sdb, because sdb is assigned in probe order and can point at a different disk after a reboot.
lsblk -o NAME,SIZE,TYPE,MOUNTPOINTS
sudo zpool create -o ashift=12 -O compression=zstd -O atime=off backup /dev/disk/by-id/DISK-ID
sudo zpool status backupashift=12 fixes the sector size at 4 KiB, which matches every modern disk and cannot be changed after creation. compression=zstd on the root dataset is inherited by everything under it, so received data lands compressed even when the sender's dataset was not. The speed of a receive is bounded by what a storage VPS disk can actually write, so do not expect the first full send to run faster than that.
Now create the account the sender logs in as. The receive has to run as root, so give the account sudo for the zfs binary and nothing else.
sudo useradd -m -s /bin/bash zfsrecv
echo 'zfsrecv ALL=(root) NOPASSWD: /usr/sbin/zfs' | sudo tee /etc/sudoers.d/zfsrecv
sudo chmod 440 /etc/sudoers.d/zfsrecv
sudo install -d -m 700 -o zfsrecv -g zfsrecv /home/zfsrecv/.sshzfs allow can delegate receive to an unprivileged account instead. On Linux the mount permission cannot be delegated (the zfs-allow(8) page says so, because mount(8) restricts the global namespace to root), so a delegated receive must use -u and the replica stays unmounted. That is acceptable for a backup target, but the sudo route is simpler and is what the rest of this guide uses.
On the sender, work as root from here on (sudo -i), because the snapshots and the SSH key both belong to root, and the timer in a later section runs as root too. Generate a key and put the public half on the VPS.
ssh-keygen -t ed25519 -f /root/.ssh/zfsrecv -N ''
cat /root/.ssh/zfsrecv.pubPaste that one line into /home/zfsrecv/.ssh/authorized_keys on the VPS, then chown zfsrecv:zfsrecv and chmod 600 the file. Test the whole path once and accept the host key when asked:
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com sudo zfs versionIt should print the VPS's zfs version output. sudo: a terminal is required to read the password means the sudoers line did not match, usually a typo in the path; which zfs on the VPS confirms it is /usr/sbin/zfs.
The first full send over SSH
Take a snapshot on the sender and send it. A snapshot is the unit of replication: zfs send never sends a live dataset, only a frozen point in time.
zfs snapshot tank/data@2026-09-18
zfs send -v tank/data@2026-09-18 |
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com \
sudo zfs receive -u -o readonly=on backup/data-v prints the estimated size, then one progress line per second, all on stderr, so it never pollutes the stream. On the VPS, backup/data is created and the snapshot appears inside it. -u and -o readonly=on are explained in the receive-side section below. Confirm it arrived:
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com sudo zfs list -t snapshot -r backup/dataExpected output is one line, backup/data@2026-09-18, with a USED of 0 and a REFER close to what the snapshot references on the sender. It will not be identical when the two datasets compress differently, and that is normal.
Incremental sends: -i versus -I
zfs snapshot tank/data@2026-09-19
zfs send -v -i tank/data@2026-09-18 tank/data@2026-09-19 |
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com sudo zfs receive -u backup/data-i takes two snapshots: the incremental source, which both sides must already hold, and the target. The stream carries only the blocks that changed between them. The source may be written as just @2026-09-18, and it may be a bookmark instead of a snapshot, which the repair section below uses.
-I (capital i) sends every intermediate snapshot as well. With daily snapshots on the sender and a push that runs weekly, -i gives the VPS one snapshot per week and -I gives it all seven. -I accepts snapshots only, not bookmarks.
zfs send -v -I tank/data@2026-09-18 tank/data@2026-09-25 |
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com sudo zfs receive -u backup/dataFor a whole tree of datasets, -R replicates the named dataset and every descendant, and -R -I does that incrementally. -R implies -p, so the stream carries dataset properties, including mountpoint. Received as-is, a child with mountpoint=/srv/data on the sender tries to mount at /srv/data on the VPS. Add -x mountpoint on the receive to keep the VPS's inherited mountpoint instead. -R combined with -F on the receive also destroys snapshots on the VPS that no longer exist on the sender. That keeps the two trees identical, and it is dangerous when the sender's retention is shorter than the retention you want on the backup.
Compression in flight: zfs send -c versus gzip or zstd
There is no zfs receive --gzip. The receive command has no compression flag at all, and a search for "zfs receive gzip" usually comes from someone who wants a smaller transfer and is looking at the wrong layer. Compression lives in three places, and each one is a separate decision.
On disk, on the VPS. The compression property of the receiving dataset decides how new blocks are written there. -o compression=zstd on the receive sets it. This has nothing to do with the size of the stream crossing the network.
In the stream, for free. zfs send -c (long form --compressed) keeps blocks that are already compressed on the sender's disk in their compressed form. If tank/data has compression=lz4 or zstd, the stream is about the size of the data on disk, and neither side spends CPU on it. The receiving pool must have the matching feature enabled (lz4_compress or zstd_compress); any pool created by OpenZFS 2.x has both. One catch: a dataset with recordsize above 128 KiB needs -L (--large-block) next to -c, otherwise the sender decompresses those large blocks to split them and the saving is lost. -Lc is the usual pair.
In the stream, with a pipe. For a sender dataset with compression=off, -c has nothing to keep and the stream is raw. A pipe through an external compressor helps on a slow uplink:
apt install -y zstd
zfs send -v tank/data@2026-09-19 | zstd -T0 -3 |
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com 'zstd -d | sudo zfs receive -u backup/data'Install zstd on the VPS as well, since the decompressor runs there. zstd -T0 uses every core. gzip is the tool people reach for and the wrong one: it runs on a single core, and a single core of gzip compresses more slowly than a gigabit link carries the raw bytes, so the pipe becomes the bottleneck. ssh -C is gzip under another name and has the same problem. If you use a pipe, use zstd, and skip the pipe entirely when the link is fast or the dataset is already compressed.
Blocks that arrive through -c are written on the VPS exactly as they were compressed on the sender, whatever the receiving dataset's compression property says. That property only shapes blocks written later, and on a readonly=on replica there are none. So -o compression=zstd on the receiver pays off only when the sender's dataset was uncompressed and the send did not use -c. Piping through gzip and then setting compression=gzip on the receiver, which is what the "zfs receive gzip" search usually ends up doing, spends CPU twice on the same bytes.
SSH itself encrypts the stream, and that costs CPU on both ends. On a typical VPS uplink it does not matter. If top shows sshd at 100% on one core while the disk idles, check that AES-NI is exposed to the VPS, because an AES-GCM cipher without it runs in software.
Receive-side properties: -u, readonly=on, -o compression=
-u leaves the received filesystem unmounted. A backup replica has no reason to be mounted, and an unmounted dataset cannot be changed by a stray process or an access-time update. It also avoids the mountpoint collision described above.
-o readonly=on sets the property exactly as zfs set readonly=on would, just before the receive. It is a local property on backup/data, so it survives every later incremental without being repeated. The reason it matters is the incremental check: zfs receive refuses an incremental into a dataset that changed after its newest snapshot, and a writable replica that someone mounted and browsed fails with exactly that error, shown in the next section. readonly=on plus -u is the pair that keeps a replica receivable.
-o property=value works for any property, compression=zstd included. -x property does the opposite for -p and -R streams: the receive ignores that property from the stream and keeps the local or inherited value.
-F forces a rollback of the target to its newest snapshot before receiving. It is the fix for a modified replica and the wrong thing to put in a script by default, because it silently discards whatever changed on the VPS side.
-d and -e derive the target name from the sent name: -d drops the pool name from the sent path and -e keeps only the last component. They matter for -R streams of whole trees. For a single dataset, naming the target explicitly is clearer.
Resumable sends: receive -s and send -t
A 900 GB first send over a home uplink runs for a day, and a dropped SSH session at hour 20 throws all of it away unless the receive was resumable. -s on the receive keeps the partially received state instead of deleting it.
zfs send -v tank/data@2026-09-18 |
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com \
sudo zfs receive -s -u -o readonly=on backup/dataWhen that stream is cut, the receive fails with:
cannot receive new filesystem stream: checksum mismatch or incomplete stream.
Partially received snapshot is saved.
A resuming stream can be generated on the sending system by running:
zfs send -t 1-1460efcfae-148-789c636064000310a500c4ec50360710e72765a5...The long value is the resume token. It is also stored on the VPS as the receive_resume_token property of backup/data, and it encodes everything the sender needs: the snapshot and byte offset it stopped at, and which of -c, -L and -e the original send used. Read it back and resume:
token=$(ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com sudo zfs get -H -o value receive_resume_token backup/data)
zfs send -v -t "$token" |
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com sudo zfs receive -s -u backup/data-t takes only the token, with no dataset names and no -c; the token already carries them. Keep -s on the resumed receive too, or a second cut throws the progress away. When the property reads -, there is nothing to resume. To discard a partial receive on purpose, run sudo zfs receive -A backup/data on the VPS. The feature needs extensible_dataset enabled on the receiving pool, which every pool made by OpenZFS 2.x has.
Why snapshots on both ends must line up
An incremental stream is a diff from one snapshot to another. The zfs-receive(8) page states the rule: the destination must already exist, and "its most recent snapshot must match the incremental stream's source". Both ends therefore need one snapshot in common, and on the VPS it must be the newest one. Breaking that rule produces one of four errors.
The common snapshot is gone from the sender. Retention on the sender destroyed @2026-09-18, so the next -i @2026-09-18 cannot start; zfs send fails before a byte leaves. If you kept a bookmark, send from it. A bookmark records the point in time a snapshot was taken. It occupies no space and is valid as the -i source:
zfs bookmark tank/data@2026-09-18 tank/data#2026-09-18
zfs destroy tank/data@2026-09-18
zfs send -v -i tank/data#2026-09-18 tank/data@2026-09-19 |
ssh -i /root/.ssh/zfsrecv zfsrecv@vps.example.com sudo zfs receive -u backup/dataMake the bookmark before the destroy, never after, because a bookmark can only be made from a snapshot that still exists. Without a bookmark, the only repair is a full send into a new dataset name, then destroy the old replica once it lands.
The VPS holds a newer snapshot than the source. The receive fails with:
cannot receive incremental stream: most recent snapshot of backup/data does not match incremental sourcezfs list -t snapshot -r backup/data on the VPS shows which snapshot is newest there. Usually it is one an earlier -I delivered, so send from that snapshot instead. If someone took a snapshot of the replica by hand, zfs destroy backup/data@name on the VPS removes it, and the incremental then goes through.
The replica was modified.
cannot receive incremental stream: destination backup/data has been modified since most recent snapshotSomething wrote to backup/data after its newest snapshot. Mounting a writable dataset with atime=on is enough: reading a file updates its access time, and that is a write. zfs receive -F rolls the replica back to its newest snapshot and then receives. Set readonly=on afterwards and keep the dataset unmounted so it does not happen again.
A full stream was aimed at an existing dataset.
cannot receive new filesystem stream: destination 'backup/data' exists
must specify -F to overwrite itA script lost track of the last common snapshot and sent a full stream instead of an incremental. Do not add -F. Find the newest snapshot on the VPS and send incrementally from it.
A minimal systemd timer wrapper
The script below does the whole job for one dataset: finish an interrupted receive if there is one, take a snapshot, ask the VPS what it already has, then send a full stream the first time and an incremental -I stream after that. Save it as /usr/local/sbin/zfs-push.sh and chmod 700 it.
#!/bin/bash
set -euo pipefail
src="tank/data"
dst="backup/data"
remote="zfsrecv@vps.example.com"
ssh="ssh -i /root/.ssh/zfsrecv -o BatchMode=yes"
# 1. Resume an interrupted receive before anything else.
token=$($ssh "$remote" sudo zfs get -H -o value receive_resume_token "$dst" 2>/dev/null || true)
if [ -n "$token" ] && [ "$token" != "-" ]; then
zfs send -t "$token" | $ssh "$remote" sudo zfs receive -s -u "$dst"
fi
# 2. New snapshot, named by UTC time.
new="${src}@$(date -u +%Y-%m-%d-%H%M)"
zfs snapshot "$new"
# 3. Newest snapshot the VPS already holds, as "@name". Empty on the first run.
last=$($ssh "$remote" sudo zfs list -H -d 1 -t snapshot -o name -s creation "$dst" 2>/dev/null |
tail -n 1 | sed 's/^.*@/@/' || true)
# 4. Full send once, incremental after that.
if [ -z "$last" ]; then
zfs send -Lc "$new" | $ssh "$remote" sudo zfs receive -s -u -o readonly=on "$dst"
else
zfs send -Lc -I "${src}${last}" "$new" | $ssh "$remote" sudo zfs receive -s -u "$dst"
fiset -o pipefail is what makes the script honest: without it, a zfs send that fails mid-pipe leaves the exit status of ssh, and systemd would record success. BatchMode=yes makes SSH fail instead of waiting for a password or a host-key prompt, which is why you accepted the host key by hand earlier. Step 4 relies on the sender still holding the snapshot the VPS reports; if local retention destroyed it, zfs send exits non-zero and the unit shows as failed, which is the right outcome. Run the script once by hand and check the VPS before wiring up the timer.
Two unit files. /etc/systemd/system/zfs-push.service:
[Unit]
Description=Push tank/data snapshots to the storage VPS
After=network-online.target zfs.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/zfs-push.shAnd /etc/systemd/system/zfs-push.timer:
[Unit]
Description=Nightly ZFS push to the storage VPS
[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.targetsystemctl daemon-reload
systemctl enable --now zfs-push.timer
systemctl list-timers zfs-push.timer
systemctl start zfs-push.service && journalctl -u zfs-push.service -n 20list-timers shows the next run. Persistent=true runs a missed push at the next boot instead of skipping it. If journalctl shows Failed with result 'exit-code', read the lines above it: they are the stderr of zfs send or zfs receive, and they contain one of the four messages from the previous section.
That script stops being enough the moment you need retention on both ends that never destroys the common snapshot, recursive trees with per-dataset state, holds that stop a snapshot from being pruned while a send is running, or more than one target. That is where sanoid and syncoid take over. Both come from one package in the Ubuntu universe repository, sanoid 2.2.0 on 24.04 and 2.3.0 on 26.04 as of September 2026.
apt install -y sanoidSanoid takes and prunes snapshots from a policy in /etc/sanoid/sanoid.conf on its own timer (systemctl enable --now sanoid.timer). Syncoid replaces the script above: it finds the newest common snapshot by itself, uses resumable streams when both ends support them, and runs zfs through sudo on a remote account that is not root, so the zfsrecv account works unchanged.
syncoid --no-sync-snap --sshkey=/root/.ssh/zfsrecv --compress=zstd-fast tank/data zfsrecv@vps.example.com:backup/data--no-sync-snap tells syncoid to ship the snapshots sanoid already took rather than adding its own. Pass --compress explicitly; the default is lzo, which needs the lzop binary on both ends. Whether hand-rolled or syncoid, the pool on the VPS still needs a scrub schedule, and zfsutils-linux installs a monthly one in /etc/cron.d/zfsutils-linux. Whether that is often enough is a question of how often a small pool should scrub, and on a single-disk VPS pool a scrub can detect damage but never repair it.
zfs clone and zfs promote on the replica
The replica is read-only and unmounted, which is right for receiving and useless for looking at a file. zfs clone makes a writable dataset from a snapshot in seconds, sharing every unchanged block with it.
sudo zfs clone backup/data@2026-09-18 backup/data-look
ls /backup/data-look
sudo zfs destroy backup/data-lookThe clone is a child of backup, not of backup/data, so it inherits readonly=off and the pool's mountpoint, and it mounts at /backup/data-look. It costs nothing until you write to it. While it exists, the snapshot it came from cannot be destroyed: zfs destroy backup/data@2026-09-18 refuses with snapshot has dependent clones, and the origin property of the clone names that snapshot.
zfs promote reverses that dependency. Run zfs promote backup/data-look and the clone becomes the parent: the origin snapshot and every snapshot older than it move to the clone, and backup/data becomes a clone of backup/data-look that can now be destroyed. The use case is a restore or a migration on the sender, not a browse on the VPS: you clone tank/data@2026-09-18 as tank/data-fixed, repair something in it, promote it, and rename it into place. Never promote a clone of a replica you are still receiving into. The snapshots the incremental chain depends on move to the promoted clone, backup/data no longer holds them, and the next -i fails with the mismatch error above.
To restore for real, send the other way. sudo zfs send -Lc backup/data@2026-09-18 on the VPS piped into zfs receive tank/data-restore on the sender is the same command in reverse, and it is the test that proves the replica is a backup.
When the sender is not ZFS: restic and borg
Send and receive need ZFS on both ends. The stream is a copy of blocks, so it preserves snapshots and dataset properties exactly, and it costs nothing to work out what changed, because ZFS already knows which blocks are newer than the source snapshot. That is the whole reason a nightly incremental of a large dataset takes minutes rather than a walk over every file.
The same design has limits. Nothing is encrypted at rest on the VPS unless the sender's dataset is encrypted and sent with -w (raw), in which case the VPS stores ciphertext it cannot read, which is the model encrypting your data on a storage VPS describes. Nothing is deduplicated across hosts. A push with -F can destroy history on the VPS, which is why a replica is only a backup rather than a mirror when the receiving side keeps snapshots the sender cannot delete. Pulling from the VPS instead of pushing, so the sender holds no credentials to the backup, is the usual answer to that.
If the sender is a laptop on ext4 or a NAS without ZFS, restic or borg fit better. Both walk the filesystem, chunk and deduplicate the data, encrypt it, and store it in a repository that can live on any VPS used as an offsite backup target over SSH, with no ZFS on either end. The trade-off is time: every run reads file metadata across the whole tree, and a first backup of terabytes takes as long as reading them. Restic versus BorgBackup compares the two, and backing up a NAS to a storage VPS shows both paths from a NAS's point of view, ZFS replication where the NAS runs ZFS and restic where it does not.
FAQ
Can I compress a ZFS stream with gzip on the receive side?
No. zfs receive has no compression option. Compression on the wire belongs to the sender: zfs send -c keeps already-compressed blocks compressed for free, and a pipe through zstd -T0 handles an uncompressed dataset. Compression on disk belongs to the receiving dataset's compression property, set with -o compression=zstd on the receive. gzip in a pipe is single-threaded and slower than the link, so avoid it.
Why does my incremental zfs send fail with "does not match incremental source"?
The newest snapshot on the VPS is not the snapshot you named as the -i source. Run zfs list -t snapshot -r backup/data on the VPS, find its newest snapshot, and send from that one. If the sender no longer has that snapshot, send from a bookmark of it if you made one, otherwise do a full send into a new dataset name. If the VPS has an extra snapshot someone took by hand, destroy it there and retry.
Is a ZFS replica on a storage VPS a real backup?
It is a backup when it holds snapshots the sender cannot delete and when you have restored from it at least once. A push script with -F on the receive can roll the replica back, and -R -F can destroy snapshots on the VPS, so keep -F out of routine runs, keep the replica readonly=on, and prefer pulling from the VPS to pushing to it if the sender is exposed. Test the restore by sending a snapshot back the other way.
Can I send from FreeBSD or TrueNAS to a Linux storage VPS?
Yes. OpenZFS on FreeBSD and OpenZFS on Linux produce and accept the same stream format, so zfs send on a FreeBSD NAS piped into zfs receive on an Ubuntu VPS works, as long as the receiving pool has the features the stream uses, such as lz4_compress for -c and large_blocks for -L. A pool that lacks one fails with pool must be upgraded to receive this stream. SSD Nodes does not offer FreeBSD images, so the receiving side is Linux.