ZFS send/receive target
Stream ZFS snapshots directly to CapyArk using zfs send | ssh ... zfs receive. Block-level incrementals — only changed data ever travels the wire.
Why ZFS send/receive
Unlike rsync, zfs send streams the entire ZFS snapshot state — metadata, permissions, extended attributes, and block-level change tracking — all in one atomic operation. The destination ends up with an exact replica of your source dataset, including the full snapshot history you choose to send.
Use this method if:
- Your source machine already uses ZFS (common on Linux, FreeBSD, TrueNAS, Proxmox)
- You want to preserve ZFS snapshot history at the destination
- You need the most bandwidth-efficient incrementals (block-level, not file-level)
For file-based backups without ZFS at the source, use rsync instead.
Prerequisites
- ZFS installed on the source machine (
zfs --version) - At least one snapshot on the source dataset to send
- Tailscale connected and your storage node online
Step 1 — Create your first snapshot
If you don’t have one yet:
zfs snapshot tank/mydata@backup-2026-01-01
Replace tank/mydata with your pool and dataset name.
Step 2 — Send the initial full stream
The first send is a full stream — it transfers all data in the snapshot.
zfs send tank/mydata@backup-2026-01-01 \
| ssh storage-baas_xxxxx \
zfs receive -F tank/clients/baas_xxxxx/zfs_streams/mydata
What tank/clients/baas_xxxxx/zfs_streams/mydata means:
tank— the ZFS pool on the CapyArk storage serverclients/baas_xxxxx— your dedicated dataset (yourinternal_client_id)zfs_streams/mydata— a sub-dataset for your stream, name it anything
Your
internal_client_idis shown in the portal under Storage → Dataset and looks likebaas_xxxxx.
The -F flag on zfs receive forces the receive even if the destination already has changes — safe for an initial import.
Step 3 — Send incremental snapshots
After the initial send, create incremental updates by referencing the last-sent snapshot:
# Create a new snapshot
zfs snapshot tank/mydata@backup-2026-01-02
# Send only the delta from the previous snapshot
zfs send -i tank/mydata@backup-2026-01-01 tank/mydata@backup-2026-01-02 \
| ssh storage-baas_xxxxx \
zfs receive tank/clients/baas_xxxxx/zfs_streams/mydata
Only blocks that changed between the two snapshots are streamed. For typical workloads this is a fraction of total dataset size.
Step 4 — Automate with a script
Save as /usr/local/bin/capyark-zfs-backup:
#!/usr/bin/env bash
set -euo pipefail
DATASET="tank/mydata"
REMOTE_HOST="storage-baas_xxxxx"
REMOTE_DATASET="tank/clients/baas_xxxxx/zfs_streams/mydata"
SNAP_PREFIX="backup"
DATE=$(date +%Y-%m-%d)
NEW_SNAP="${DATASET}@${SNAP_PREFIX}-${DATE}"
# Find the most recent snapshot already at the destination
LAST_REMOTE=$(ssh "$REMOTE_HOST" \
zfs list -H -t snapshot -o name -s creation "$REMOTE_DATASET" 2>/dev/null \
| tail -1 \
| sed "s|${REMOTE_DATASET}@||")
# Create today's snapshot
zfs snapshot "$NEW_SNAP"
if [ -z "$LAST_REMOTE" ]; then
echo "No remote snapshot found — sending full stream"
zfs send "$NEW_SNAP" | ssh "$REMOTE_HOST" zfs receive -F "$REMOTE_DATASET"
else
echo "Sending incremental: ${LAST_REMOTE} → ${DATE}"
zfs send -i "${DATASET}@${LAST_REMOTE}" "$NEW_SNAP" \
| ssh "$REMOTE_HOST" zfs receive "$REMOTE_DATASET"
fi
echo "Done: $NEW_SNAP → $REMOTE_DATASET"
chmod +x /usr/local/bin/capyark-zfs-backup
Schedule it daily with a systemd timer or cron:
0 3 * * * /usr/local/bin/capyark-zfs-backup >> /var/log/capyark-zfs.log 2>&1
Useful send options
# Include all child datasets (recursive send)
zfs send -R tank/mydata@backup-2026-01-01 | ssh storage-baas_xxxxx zfs receive ...
# Compressed stream (reduces bandwidth — especially useful for uncompressed datasets)
zfs send -Lc tank/mydata@backup-2026-01-01 | ssh storage-baas_xxxxx zfs receive ...
# -L: large blocks (matches source block size)
# -c: embed compressed data as-is (avoids double-decompress-recompress)
# Dry run — estimate transfer size without sending
zfs send -nv -i tank/mydata@snap1 tank/mydata@snap2
# Output: "size estimate: X MB"
# Verbose — show progress during send
zfs send -v tank/mydata@snap | ssh storage-baas_xxxxx zfs receive ...
Viewing received snapshots
From the portal, your snapshots appear in Backups with the zfs_streams/ prefix. You can also list them over SSH:
ssh storage-baas_xxxxx zfs list -t snapshot tank/clients/baas_xxxxx/zfs_streams/mydata
Restoring
See Restore from a snapshot for portal-based restore and CLI methods.
For a full dataset restore via zfs send (source ↔ destination reversed):
ssh storage-baas_xxxxx zfs send tank/clients/baas_xxxxx/zfs_streams/mydata@backup-2026-01-02 \
| zfs receive -F tank/mydata-restored
Troubleshooting
cannot receive: destination has been modified since most recent snapshot
The destination dataset has local changes that don’t match the expected snapshot chain. Fix with -F on the receive, or roll back the destination: ssh storage-baas_xxxxx zfs rollback tank/.../zfs_streams/mydata@lastsnap.
zfs: command not found on the remote
CapyArk’s SSH server is a restricted container — zfs receive is in the allow-list. Check that you’re connecting to the right host and that the path starts with tank/clients/baas_xxxxx/.
permission denied on the remote dataset path
The destination path must be under tank/clients/<your-internal-client-id>/. Paths outside your dataset will be rejected.