Your Off-Site Backup Is One Fire Away From Gone
The 3-2-1 rule. Three copies, two media types, one off-site. You’ve heard it. You probably have two of the three. The on-site stuff is solid — ZFS pool on your NAS, snapshots running, maybe even RAIDZ2. You’re feeling smug about it.
Then your friend asks “so where’s the off-site?” and you mumble something about meaning to set that up.
Here’s the thing: off-site doesn’t have to mean paying rsync.net $20/month forever or shipping drives to your mom’s house every quarter. If you have a friend with a NAS, a cheap VPS with some attached storage, or even rsync.net (which does support native ZFS, to be fair), you can pipe your ZFS snapshots over WireGuard and call it done. Encrypted in transit. Encrypted at rest if your source datasets are native ZFS encrypted. Incremental after the first run, so you’re not blasting your ISP upload cap every night.
Let’s build it.
What You’ll Need
- ZFS on both ends (same or compatible pool version — more on this)
- WireGuard installed on both machines
- SSH access between the two boxes
- About an hour of your time and a willingness to read a
man zfspage
The remote side can be:
- A friend’s TrueNAS, Proxmox host, or plain Ubuntu box with ZFS
- A VPS with a ZFS-capable kernel and attached block storage (Hetzner, Vultr, DigitalOcean all work)
- rsync.net (their higher-tier plans have native
zfs receive— legitimately good)
Step 1: WireGuard Tunnel
WireGuard is the transport. All your snapshot data flows through it — encrypted, fast, no nonsense. If you already have a WireGuard tunnel between your home and the remote site, skip this section. Otherwise, here’s a minimal config.
On the home server (initiator):
[Interface]PrivateKey = <HOME_PRIVATE_KEY>Address = 10.99.0.1/30ListenPort = 51820
[Peer]PublicKey = <REMOTE_PUBLIC_KEY>AllowedIPs = 10.99.0.2/32Endpoint = remote.example.com:51820PersistentKeepalive = 25On the remote server (receiver):
[Interface]PrivateKey = <REMOTE_PRIVATE_KEY>Address = 10.99.0.2/30ListenPort = 51820
[Peer]PublicKey = <HOME_PUBLIC_KEY>AllowedIPs = 10.99.0.1/32PersistentKeepalive = 25Generate key pairs with:
wg genkey | tee private.key | wg pubkey > public.keyBring the interface up on both sides:
systemctl enable --now wg-quick@wg-backupConfirm connectivity:
ping 10.99.0.2 # from home serverping 10.99.0.1 # from remote serverThe PersistentKeepalive = 25 matters if either side is behind NAT. It keeps the UDP hole punched open so your backup job doesn’t stall after an hour of inactivity.
Step 2: SSH Over WireGuard
ZFS send/receive travels over SSH. You want SSH to go through the WireGuard tunnel — specifically, SSH to the WireGuard IP, not the public IP. This way everything is double-encrypted (WireGuard + SSH) and you’re not depending on the remote box having a public IP that’s open on port 22.
Set up a dedicated user on the remote for receiving snapshots. Call them zfsreceive or whatever you like.
# On remoteuseradd -m -s /bin/bash zfsreceivemkdir -p /home/zfsreceive/.sshchmod 700 /home/zfsreceive/.sshGenerate an SSH key on the home server (no passphrase — this is a daemon, not a human):
# On home server, as root or the user running backupsssh-keygen -t ed25519 -f ~/.ssh/zfs_backup -N ""Copy the public key to the remote:
ssh-copy-id -i ~/.ssh/zfs_backup.pub zfsreceive@10.99.0.2Test it:
ssh -i ~/.ssh/zfs_backup zfsreceive@10.99.0.2 "echo hello from the other side"Now give zfsreceive permission to run zfs receive without a full sudo prompt. On the remote:
echo 'zfsreceive ALL=(root) NOPASSWD: /sbin/zfs' | sudo tee /etc/sudoers.d/zfsreceiveStep 3: Understanding zfs send Flags
Before you pipe anything anywhere, know what you’re sending.
# Full send of a single snapshotzfs send tank/data@2026-05-21 | ...
# Incremental from previous snapshot to latestzfs send -i tank/data@2026-05-20 tank/data@2026-05-21 | ...
# Recursive + all intermediary snapshots (what you usually want)zfs send -RIv tank/data@2026-05-21 | ...The flags that matter:
-R— recursive, includes all child datasets-I— includes all intermediate snapshots between the last sent and the current one (lowercase-iskips intermediaries)-v— verbose output so you know it’s actually doing something-w— raw send for encrypted datasets; the ciphertext travels as-is, and the remote gets an encrypted replica it can’t read without your key (which is what you want for cold off-site storage)-c— preserve compression on the stream (saves bandwidth if your dataset is compressed)
The -w flag is critical if your source datasets use native ZFS encryption. Without it, zfs send decrypts before streaming. With -w, the remote holds encrypted data and needs your encryption key to do anything with it. For off-site cold storage where you don’t trust the remote operator, use -w and don’t hand over your keys.
Step 4: First Full Send
The first send is always the biggest. Size it up first:
zfs send -Rv tank/data@2026-05-21 | wc -cOr get a rough estimate from the dataset:
zfs list -o name,used,refer tank/dataThen pipe it over SSH to the remote. On the remote, create the destination pool first if it doesn’t exist (e.g., backup pool on /dev/sdb):
# On remote — one-time setupzpool create backup /dev/sdbNow send:
# On home serverzfs send -Rv tank/data@2026-05-21 | \ ssh -i ~/.ssh/zfs_backup zfsreceive@10.99.0.2 \ "sudo zfs receive -F backup/data"For encrypted datasets:
zfs send -Rvw tank/data@2026-05-21 | \ ssh -i ~/.ssh/zfs_backup zfsreceive@10.99.0.2 \ "sudo zfs receive -F backup/data"This is going to take a while on the first run. That’s normal. Go touch grass.
Step 5: Incremental Sends and Resume Tokens
After the full send, every subsequent send is incremental. Snapshots need to exist on both sides:
# Home server: create a new snapshotzfs snapshot tank/data@2026-05-22
# Send the incrementzfs send -Riv tank/data@2026-05-21 tank/data@2026-05-22 | \ ssh -i ~/.ssh/zfs_backup zfsreceive@10.99.0.2 \ "sudo zfs receive backup/data"What Happens If It Dies Halfway?
ZFS has resume tokens. If a send gets interrupted — power outage, WireGuard flap, your cat sitting on the keyboard — you don’t start over from scratch.
On the remote, after an interrupted receive:
# On remotesudo zfs get receive_resume_token backup/dataYou’ll get something like 1-...a long hex string.... Copy it. On the home server:
zfs send -t <RESUME_TOKEN> | \ ssh -i ~/.ssh/zfs_backup zfsreceive@10.99.0.2 \ "sudo zfs receive backup/data"ZFS picks up exactly where it left off. It’s one of those features that makes you realize how much thought went into this filesystem.
Step 6: Bandwidth Shaping with pv or mbuffer
If you’re sharing an upload connection with other humans or services, you don’t want your backup job hammering 40 Mbps of upstream at 9 PM. Enter pv (pipe viewer) or mbuffer.
With pv — rate limit and see progress:
zfs send -Riv tank/data@2026-05-21 tank/data@2026-05-22 | \ pv --rate-limit 10m | \ ssh -i ~/.ssh/zfs_backup zfsreceive@10.99.0.2 \ "sudo zfs receive backup/data"10m = 10 MB/s. Adjust to taste.
With mbuffer — buffering for bursty connections:
zfs send -Riv tank/data@2026-05-21 tank/data@2026-05-22 | \ mbuffer -s 128k -m 1G -r 10M | \ ssh -i ~/.ssh/zfs_backup zfsreceive@10.99.0.2 \ "mbuffer -s 128k -m 1G | sudo zfs receive backup/data"mbuffer on both ends smooths out the bursty nature of ZFS send streams and handles TCP buffering better than raw pipes over high-latency links. If your friend is three states away and you’re seeing stalls, mbuffer helps.
Step 7: Automating With Syncoid
Manual sends are fine for occasional use. For daily off-site replication, you want automation. Syncoid (part of the sanoid suite) handles the entire incremental send/receive dance for you — it figures out the common snapshot, sends the delta, and handles the edge cases.
Install sanoid on the home server:
# Debian/Ubuntuapt install libcapture-tiny-perl libconfig-inifiles-perl pv lzop mbuffergit clone https://github.com/jimsalterjrs/sanoid /opt/sanoidln -s /opt/sanoid/syncoid /usr/local/bin/syncoidRun a syncoid replication:
syncoid \ --sshkey=/root/.ssh/zfs_backup \ --compress=lz4 \ --no-privilege-elevation \ tank/data \ zfsreceive@10.99.0.2:backup/dataFor encrypted datasets, add --sendoptions=w:
syncoid \ --sshkey=/root/.ssh/zfs_backup \ --sendoptions=w \ --compress=none \ tank/data \ zfsreceive@10.99.0.2:backup/data(Don’t compress raw-encrypted streams — the data is already encrypted and random-looking. Compression will just burn CPU for nothing.)
Automating With Cron
# Run daily at 2 AMcrontab -e0 2 * * * /usr/local/bin/syncoid --sshkey=/root/.ssh/zfs_backup --sendoptions=w tank/data zfsreceive@10.99.0.2:backup/data >> /var/log/syncoid.log 2>&1When is manual zfs send better than syncoid?
- One-time migrations or disaster recovery restores
- When you need precise control over which snapshots travel
- When the remote pool has mismatched dataset structure and syncoid gets confused
- When you’re piping through
mbufferin a specific way and syncoid’s flags don’t quite fit
Syncoid wins for day-to-day automated replication. Manual wins for surgical operations.
Step 8: Snapshot Retention on the Remote
The remote side accumulates snapshots fast. You need a retention policy there too. You can run sanoid on the remote to prune old snapshots, or use a simple script.
On the remote:
# Keep last 30 daily snapshots, nuke the restzfs list -t snapshot -o name -s creation backup/data | \ head -n -30 | \ xargs -r -n1 zfs destroyOr configure sanoid on the remote with a monitor_only = no config so it handles pruning of received snapshots. The sanoid README covers this in detail.
Pool Version and Encryption Key Caveats
A few things that will bite you if you skip them:
Pool version matching: The sender’s pool must not be newer than the receiver’s. ZFS is forward-compatible (newer can receive from older) but not backward-compatible. If your home NAS is running OpenZFS 2.2 and your friend’s box is on 2.0, you’re fine. Reversed — not fine. Check with:
zpool get version tank # should say "5" (current standard)zfs get version tank/data # dataset versionEncryption key handling on receive: If you’re using -w (raw encrypted send), the remote stores ciphertext. The receive side does NOT need your encryption key to store the data. But if you ever need to restore or mount the dataset on the remote (disaster recovery), you’ll need to load your encryption key there:
# On remote, during DRsudo zfs load-key backup/data# Enter your passphrase or provide keyfilesudo zfs mount backup/dataKeep your encryption key stored somewhere safe that isn’t your home NAS. A password manager, an offline USB, your own head — pick one. A backup you can’t restore is just storage theater.
Unencrypted source + encrypted destination: If your source isn’t ZFS-encrypted but you want the remote copy encrypted, you can’t do it at the ZFS layer during send. You’d need to encrypt at the WireGuard + SSH layer (already done) and accept that the remote sees plaintext ZFS data. Or encrypt the source datasets first.
A Worked Example: Home Pool to Friend’s House
Here’s the full picture for the “friend with a NAS” scenario:
Home: tank/data (encrypted, daily snapshots via sanoid)Remote: friend's TrueNAS, backup poolWireGuard: 10.99.0.1 ↔ 10.99.0.2Sanoid config on home server (/etc/sanoid/sanoid.conf):
[tank/data] use_template = production
[template_production] frequently = 0 hourly = 6 daily = 30 monthly = 6 yearly = 0 autosnap = yes autoprune = yesCron on home server:
# Snapshot at midnight0 0 * * * /usr/local/bin/sanoid --take-snapshots --verbose >> /var/log/sanoid.log 2>&1# Prune old snapshots at 12:30 AM30 0 * * * /usr/local/bin/sanoid --prune-snapshots --verbose >> /var/log/sanoid.log 2>&1# Replicate at 2 AM0 2 * * * /usr/local/bin/syncoid --sshkey=/root/.ssh/zfs_backup --sendoptions=w --compress=none tank/data zfsreceive@10.99.0.2:backup/data >> /var/log/syncoid.log 2>&1That’s it. Snapshots happen, incrementals go out over WireGuard, remote side accumulates a 30-day history of your data. Your 2 AM self never has to think about it again.
The Bottom Line
zfs send | ssh is one of those tools that looks intimidating on paper and then turns out to be absurdly reliable once you’ve set it up. The WireGuard layer keeps your data encrypted in transit without opening weird firewall holes. The -w flag keeps it encrypted at rest on the remote. Resume tokens mean a flaky connection doesn’t ruin a 200 GB send.
Start with a manual send to prove the concept, then drop syncoid in and automate it. The total setup time is an afternoon, and the result is real off-site replication — not “I’ll think about it” replication. Your 3-2-1 backup strategy can finally have all three numbers.
If you’re looking for a zero-effort managed option, rsync.net’s ZFS plans are legitimately good and support native zfs receive. But honestly, if you have a friend with a NAS and a few hours, you don’t need to pay anyone. You just need WireGuard, SSH, and a little trust in OpenZFS’s 20-year track record of not losing data.