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:

For file-based backups without ZFS at the source, use rsync instead.

Prerequisites

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:

Your internal_client_id is shown in the portal under Storage → Dataset and looks like baas_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.