When I need to move ZFS snapshots between systems, especially across untrusted networks, I don’t rely on the underlying transport for confidentiality. Instead, I encrypt the stream itself using zfs send piped through OpenSSL or age, then decrypt on the receiving end. This keeps the backup data encrypted at rest and in transit without requiring full-disk encryption on the target. Here’s how I do it in my environment.
Why encrypt the stream, not just the disk?
Full-disk encryption protects data at rest, but once the backup is sent, the stream is plaintext if you use raw zfs send | ssh. An attacker sniffing the network or compromising the intermediate host could read your snapshots. By encrypting the stream, I ensure that only the holder of the decryption key can reconstruct the backup, even if the stream is intercepted or stored insecurely.
This approach also lets me send backups to remote systems that don’t support native ZFS encryption or where I don’t control the storage layer—like a rented backup server or a friend’s NAS.
Encrypting with OpenSSL (symmetric key)
For simplicity and compatibility, I often use OpenSSL with a pre-shared key. First, I generate a key and store it securely on both ends:
# Generate a 32-byte key (256-bit)
head -c 32 /dev/urandom > /root/backup.key
chmod 600 /root/backup.key
Then, on the source system, I pipe the send stream through OpenSSL encryption:
zfs send tank/data@snap1 | \
openssl enc -aes-256-cbc -md sha256 -pass file:/root/backup.key | \
ssh backup@remote 'cat > /tmp/snap1.enc'
On the remote system, I reverse the process:
ssh backup@remote 'cat /tmp/snap1.enc' | \
openssl enc -d -aes-256-cbc -md sha256 -pass file:/root/backup.key | \
zfs recv backup/tank/data
The -md sha256 ensures a strong key derivation function. I avoid -salt in automated scripts because it breaks determinism unless managed carefully—here, the key file provides sufficient entropy.
Using age for modern encryption
If I want something simpler and more modern than OpenSSL, I use age. It’s designed for file encryption and works well with streams. I encrypt with a public key and decrypt with the corresponding private key.
First, generate a key pair:
age-keygen -o /root/age.key
# Extract public key
age-keygen -y /root/age.key > /root/age.key.pub
Then send the encrypted stream:
zfs send tank/data@snap1 | \
age -r $(cat /root/age.key.pub) | \
ssh backup@remote 'cat > /tmp/snap1.age'
Decrypt and receive:
ssh backup@remote 'cat /tmp/snap1.age' | \
age -d -i /root/age.key | \
zfs recv backup/tank/data
I like age because it’s harder to misconfigure—no cipher modes, no salt management—and the keys are simple to handle.
Automating with a wrapper script
To avoid repeating the command, I use a small script that handles encryption, transfer, and decryption. Here’s a version for OpenSSL:
#!/bin/bash
set -euo pipefail
SNAPSHOT="$1"
REMOTE_HOST="$2"
REMOTE_POOL="$3"
KEYFILE="/root/backup.key"
if [[ ! -f "$KEYFILE" ]]; then
echo "Encryption key not found: $KEYFILE" >&2
exit 1
fi
zfs send "$SNAPSHOT" | \
openssl enc -aes-256-cbc -md sha256 -pass file:"$KEYFILE" | \
ssh "$REMOTE_HOST" "cat > /tmp/$(basename "$SNAPSHOT").enc"
ssh "$REMOTE_HOST" "\
openssl enc -d -aes-256-cbc -md sha256 -pass file:"$KEYFILE" < /tmp/$(basename "$SNAPSHOT").enc | \
zfs recv "$REMOTE_POOL" && \
rm -f /tmp/$(basename "$SNAPSHOT").enc"
I make it executable and call it like:
./zfs-send-encrypt.sh tank/data@snap1 backup.example.com backup/tank
The script cleans up the temporary encrypted file on the remote side after successful receipt.
Key management and rotation
I treat the encryption key like any other credential: stored in a password manager or secrets vault, rotated periodically, and never committed to version control. For automated backups, I load the key from a file with strict permissions (600) and ensure the script runs as root or a dedicated backup user.
If I’m using age, I keep the private key offline and only decrypt on the target system when needed—similar to how I handle SSH keys for critical systems.
Performance considerations
Encryption adds CPU overhead, but on modern systems, AES-NI makes OpenSSL aes-256-cbc very fast—often limited by disk or network speed rather than CPU. age uses modern cryptography (X25519, ChaCha20-Poly1305) and is also efficient.
In my tests, encrypting a 100 GB ZFS stream added less than 5% CPU usage on a Xeon E5-2680 v4 and didn’t saturate a 1 Gbps link. If you’re on older hardware, benchmark first, but for most servers, the impact is negligible.
Verifying integrity
Since encryption doesn’t affect checksums, ZFS’s built-in integrity checks still work. After receiving, I run:
zfs diff backup/tank/data@snap1 backup/tank/data@snap2
to confirm the stream was decrypted and received correctly. Any corruption in transit will cause zfs recv to fail with a checksum error.
When not to encrypt the stream
If both ends are on a trusted, isolated network (like a dedicated backup VLAN with no internet exposure), and the storage is already encrypted at rest, I might skip stream encryption for simplicity. But for anything crossing a router, firewall, or third-party infrastructure, I encrypt by default.
Final thoughts
Encrypting ZFS send/receive streams is a lightweight way to add end-to-end confidentiality to your backups without changing your storage setup. Whether you use OpenSSL for compatibility or age for modern simplicity, the key is managing the key well—treat it like a root password.
I’ve used this method for years to send daily snapshots from edge sites to a central vault, and it’s given me peace of mind knowing that even if a backup server is compromised, the data remains unreadable without the key.
If you’re already using ZFS snapshots (as I mentioned in my post on [block-level recovery](https://furkanikkan.com/urun/snapshot-yedekleme-ve-hizli-kurtarma-saniyede-block-level-geri-yukleme-64)), adding stream encryption is a natural next step for securing data in motion.
Cover image: Lenharth Systems · CC0 (Openverse / kamu malı) · https://stocksnap.io/photo/computer-hard-2J3PLNMO9M
