TL;DR

zfs send -w -R -i over SSH to zfs recv -s. The stream is not a tarball; S3 is an extra hop if needed.

zfs send emits a replicable stream, not a directory. The natural sink is another pool (zfs recv). S3 is a second hop: store the stream as an object, or use something like z3.

Incremental to another host (the command I actually run)

1
2
zfs send -w -R -v -i vol1/secure/backups@initial vol1/secure/backups@new \
  | ssh root@10.0.0.1 zfs recv -s vol1/secure/backups
FlagMeaning
-wraw: send the dataset encrypted as-is. The receiver does not need the key to recv.
-Rreplicate properties and child snapshots
-i @initial @newincremental from @initial (must exist on both sides)
-vprogress
recv -sresumable if SSH drops (recv -s again)

Hold @initial and @new while the send runs, or prune will break the chain.

The first full (no -i) is mandatory once:

1
2
zfs send -w -R -v vol1/secure/backups@initial \
  | ssh root@10.0.0.1 zfs recv -s vol1/secure/backups

Land it in S3

Same stream, other side of the pipe:

1
2
3
zfs send -w -R vol1/secure/backups@new \
  | gzip -1 \
  | aws s3 cp - s3://my-bucket/zfs/backups@new.zfs.gz

Restore:

1
2
3
aws s3 cp s3://my-bucket/zfs/backups@new.zfs.gz - \
  | gunzip \
  | zfs recv -s vol1/secure/backups

This is not a file sync. It is an opaque blob: you restore the whole stream (or the incremental on top of the full), or you restore nothing. Name @initial / @new and do not delete the full.

For incrementals in S3, tools like z3 keep a catalog of what already landed. A raw aws s3 cp - does not.

-w into S3 is correct if the dataset is already encrypted: AWS never sees plaintext. If the source is not encrypted, either encrypt on recv (post) or encrypt the object (KMS / client) — a cleartext stream in a bucket is a cleartext backup.

See also: native encryption.