Skip to content
Go back

ZFS Send/Receive Over WireGuard for Off-Site Replication

By SumGuy 12 min read
ZFS Send/Receive Over WireGuard for Off-Site Replication

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

The remote side can be:


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):

/etc/wireguard/wg-backup.conf
[Interface]
PrivateKey = <HOME_PRIVATE_KEY>
Address = 10.99.0.1/30
ListenPort = 51820
[Peer]
PublicKey = <REMOTE_PUBLIC_KEY>
AllowedIPs = 10.99.0.2/32
Endpoint = remote.example.com:51820
PersistentKeepalive = 25

On the remote server (receiver):

/etc/wireguard/wg-backup.conf
[Interface]
PrivateKey = <REMOTE_PRIVATE_KEY>
Address = 10.99.0.2/30
ListenPort = 51820
[Peer]
PublicKey = <HOME_PUBLIC_KEY>
AllowedIPs = 10.99.0.1/32
PersistentKeepalive = 25

Generate key pairs with:

Terminal window
wg genkey | tee private.key | wg pubkey > public.key

Bring the interface up on both sides:

Terminal window
systemctl enable --now wg-quick@wg-backup

Confirm connectivity:

Terminal window
ping 10.99.0.2 # from home server
ping 10.99.0.1 # from remote server

The 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.

Terminal window
# On remote
useradd -m -s /bin/bash zfsreceive
mkdir -p /home/zfsreceive/.ssh
chmod 700 /home/zfsreceive/.ssh

Generate an SSH key on the home server (no passphrase — this is a daemon, not a human):

Terminal window
# On home server, as root or the user running backups
ssh-keygen -t ed25519 -f ~/.ssh/zfs_backup -N ""

Copy the public key to the remote:

Terminal window
ssh-copy-id -i ~/.ssh/zfs_backup.pub zfsreceive@10.99.0.2

Test it:

Terminal window
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:

/etc/sudoers.d/zfsreceive
echo 'zfsreceive ALL=(root) NOPASSWD: /sbin/zfs' | sudo tee /etc/sudoers.d/zfsreceive

Step 3: Understanding zfs send Flags

Before you pipe anything anywhere, know what you’re sending.

Terminal window
# Full send of a single snapshot
zfs send tank/data@2026-05-21 | ...
# Incremental from previous snapshot to latest
zfs 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:

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:

Terminal window
zfs send -Rv tank/data@2026-05-21 | wc -c

Or get a rough estimate from the dataset:

Terminal window
zfs list -o name,used,refer tank/data

Then 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):

Terminal window
# On remote — one-time setup
zpool create backup /dev/sdb

Now send:

Terminal window
# On home server
zfs 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:

Terminal window
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:

Terminal window
# Home server: create a new snapshot
zfs snapshot tank/data@2026-05-22
# Send the increment
zfs 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:

Terminal window
# On remote
sudo zfs get receive_resume_token backup/data

You’ll get something like 1-...a long hex string.... Copy it. On the home server:

Terminal window
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:

Terminal window
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:

Terminal window
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:

Terminal window
# Debian/Ubuntu
apt install libcapture-tiny-perl libconfig-inifiles-perl pv lzop mbuffer
git clone https://github.com/jimsalterjrs/sanoid /opt/sanoid
ln -s /opt/sanoid/syncoid /usr/local/bin/syncoid

Run a syncoid replication:

Terminal window
syncoid \
--sshkey=/root/.ssh/zfs_backup \
--compress=lz4 \
--no-privilege-elevation \
tank/data \
zfsreceive@10.99.0.2:backup/data

For encrypted datasets, add --sendoptions=w:

Terminal window
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

Terminal window
# Run daily at 2 AM
crontab -e
0 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>&1

When is manual zfs send better than syncoid?

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:

Terminal window
# Keep last 30 daily snapshots, nuke the rest
zfs list -t snapshot -o name -s creation backup/data | \
head -n -30 | \
xargs -r -n1 zfs destroy

Or 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:

Terminal window
zpool get version tank # should say "5" (current standard)
zfs get version tank/data # dataset version

Encryption 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:

Terminal window
# On remote, during DR
sudo zfs load-key backup/data
# Enter your passphrase or provide keyfile
sudo zfs mount backup/data

Keep 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 pool
WireGuard: 10.99.0.1 ↔ 10.99.0.2

Sanoid config on home server (/etc/sanoid/sanoid.conf):

/etc/sanoid/sanoid.conf
[tank/data]
use_template = production
[template_production]
frequently = 0
hourly = 6
daily = 30
monthly = 6
yearly = 0
autosnap = yes
autoprune = yes

Cron on home server:

# Snapshot at midnight
0 0 * * * /usr/local/bin/sanoid --take-snapshots --verbose >> /var/log/sanoid.log 2>&1
# Prune old snapshots at 12:30 AM
30 0 * * * /usr/local/bin/sanoid --prune-snapshots --verbose >> /var/log/sanoid.log 2>&1
# Replicate at 2 AM
0 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>&1

That’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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
OpenTelemetry for Self-Hosters: Traces, Metrics, Logs Without the Datadog Bill
Next Post
K3s vs K0s vs MicroK8s: Lightweight Kubernetes for Home Labs

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts