System replication with ZFS
Using zrepl and bash, we can rebuild a system as is from incremental backups.
Published: 08/05/2026
A few months ago, I lost power during a crucial step of updating my system. This left me coming home to my system in a non-booting state. I was able to boot into a LiveCD and chroot into my system, however, upon successfully updating, and rebuilding my kernel, and initramfs, the system still wouldn’t boot. While, I am still confident this was a situation I could have rescued from, I had been wanting to make the move from btrfs to zfs for a while to try out incremental backups with zrepl. I decided to bite the bullet and reinstall my operating system, first making a backup of all of my user data.
One issue I’ve always had with my backup solutions was that in the event of an emergency, I didn’t have a full recovery solution planned. This would result in me following the exact pattern I am now. Reinstalling the operating system of my choice, and copying over useful data from backups manually. This almost always ended with a system that was only half replicated, or worse, sometimes with me missing important data entirely for months at a time. This time, I wanted to create the recovery solution as part of the process of setting up my backup solution.
Setting up zrepl
Setting up zrepl is pretty easy. My workstation is using Arch linux, so I just had to install the zrepl package, enable the service, and configure it in /etc/zrepl/zrepl.yml. I wanted to have a data-flow strategy where multiple devices (my workstation, my laptop, and my phone namely) would be able to use my home server as a central backup solution. This is known as fan-in. With zrepl, this will be accomplished by each device specifically pushing snapshots of their filesystem to the server. On the server end this is as easy as creating a job for each remote device, in nixos this looks like this:
#Backup server zrepl config
services.zrepl.enable = true;
services.zrepl.enable = true;
services.zrepl.settings = {
jobs = [
{
type = "sink";
name = "backup_sink";
root_fs = ""; #Storage location on server
recv = {
properties = {
"inherit" = [ "mountpoint" ];
override = {
canmount = "off";
atime = "off";
};
};
};
serve = {
type = "tcp";
listen = ":8888";
listen_freebind = true;
clients = {
"<CLIENT IP ADDRESS>" = "<CLIENT HOSTNAME>";
};
};
}
];
};
On the client end, I need to set up a configuration that will decide which datasets to snapshot, then connect to the server and when available, push snapshots to it. My setup takes a snapshot every 20 minutes, then, on the client, keeps the last 10 most recent snapshots. On the server we will set up a pruning policy which will keep all snapshots taken in the last hour, 24 hourly snapshots over the last day, 1 daily snapshot over the last 30 days, 12 30-day snapshots over the last year, and 1 yearly snapshot for the next 100 years (that way I’ll likely never have to think about this again). Here is what the config for this looks like:
# Client backup
global:
logging:
# use syslog instead of stdout because it makes journald happy
- type: syslog
format: human
level: warn
jobs:
- name: push_to_server
type: push
connect:
type: tcp
address: "<SERVER IP>:<PORT>"
filesystems: {
"zroot<": true,
"<DATASET TO EXCLUDE>": false
}
snapshotting:
type: periodic
prefix: zrepl_
interval: 20m
timestamp_format: human
timestamp_location: UTC
pruning:
keep_sender:
- type: not_replicated
- type: last_n
count: 10
- type: regex
regex: "^manual_.*"
keep_receiver:
- type: grid
grid: 1x1h(keep=all) | 24x1h | 30x1d | 12x30d | 100x365d # Pruning policy
regex: "^zrepl_"
- type: regex
regex: "^manual_.*"
Once the services are running on both machine, I am able to verify that snapshots are being created and sent using zfs list -t snapshot on both machines.
Installing archiso and configuring the boot environment
With my backup solution configured, I now need a way to rescue from these backups in the event of a disaster. My plan for this is to create a custom iso of the clients linux distro (in this case, arch) that will have a bash script that will automate the process of connecting to the remote server, pulling down the data and setting the filesystem properties appropriately. I want the process to be simple. Boot into the livecd, run the script, follow the instructions, and reboot. However, in order for this to work, I need to find a way to have zrepl interact with my boot partition. This isn’t directly possible because the efi partition is formatted as FAT32 instead of as a zfs dataset. In order to work around this, I will create a zboot dataset that I will sync with my actual /boot folder, then, in the recovery script, after all the data has been synced to the target machine, I will copy the contents of this dataset onto the new systems actual /boot folder.
In order to obtain this syncing from the FAT32 boot fs on the client, to zfs so that we can take said snapshots, we will use a systemd path watching our /boot partition and a hook in our package manager to run anytime a package that touches /boot is updated. Both of these will run a script we will make that will do some simple verification of the data in /boot, then use rsync to copy the data over to the zfs partition. The script can be found here, followed by the pacman hook and the systemd path unit:
#zfsbootsync script
#!/usr/bin/env bash
set -euo pipefail
SRC="/boot"
DST="/zfsboot"
log() {
echo "[zfsboot-sync] $*"
}
# --- Tests ---
# prevent concurrent instances
exec 9>/run/zfsboot-sync.lock
flock -n 9 || exit 0
# ensure paths exist
if [[ ! -d "$SRC/efi/EFI" ]]; then
log "ERROR: $SRC/efi/EFI missing, refusing to sync"
exit 1
fi
if [[ ! -d "$DST" ]]; then
log "ERROR: $DST missing"
exit 1
fi
# ensure destination is actually ZFS
if ! findmnt -n -o FSTYPE "$DST" | grep -q zfs; then
log "ERROR: $DST is not a ZFS mount"
exit 1
fi
# ensure /boot is mounted
mountpoint -q /boot/efi || {
echo "ERROR: /boot/efi not mounted"
exit 1
}
# ensure kernel + initramfs exist
[[ -s /boot/vmlinuz-linux-zen ]] || exit 1
[[ -s /boot/initramfs-linux-zen.img ]] || exit 1
# ensure initramfs is newer than kernel
if [[ /boot/initramfs-linux-zen.img -ot /boot/vmlinuz-linux-zen ]]; then
echo "ERROR: initramfs older than kernel"
exit 1
fi
# ensure microcode exists
[[ -s /boot/amd-ucode.img ]] || exit 1
# ensure rEFInd core exists
[[ -s /boot/efi/EFI/refind/refind_x64.efi ]] || exit 1
[[ -s /boot/efi/EFI/refind/refind.conf ]] || exit 1
# ensure ZFSBootMenu exists
[[ -s /boot/efi/EFI/zbm/vmlinuz-linux-zen.EFI ]] || {
echo "ERROR: missing ZBM EFI"
exit 1
}
# --- snapshot before overwrite ---
SNAP="zroot/data/zfsboot@pre-sync-$(date +%s)"
log "Creating snapshot $SNAP"
zfs snapshot "$SNAP"
# --- rsync ---
log "Syncing to staging area"
rsync -aHAX --delete \
--exclude='loader/random-seed' \
"$SRC/" \
"$DST/"
log "Sync complete"
Pacman Hook:
# /etc/pacman.d/10-zfsbootsync.hook
[Trigger]
Type = Package
Operation = Install
Operation = Upgrade
Target = zfs
Target = zfsbootmenu
Target = linux-zen
Target = linux-firmware
Target = amd-ucode
Target = refind
Target = mkinitcpio
[Action]
Description = Syncing /boot to ZFS mirror...
When = PostTransaction
Exec = /usr/local/bin/zfsbootsync
Systemd Path Unit:
#/etc/systemd/system/zfsbootsync.path
[Unit]
Description=Watch /boot for changes
[Path]
PathModified=/boot/vmlinuz-linux-zen
PathModified=/boot/initramfs-linux-zen.img
PathModified=/boot/efi/EFI
PathModified=/boot/efi/EFI/refind/refind.conf
PathModified=/boot/efi/EFI/zbm/vmlinuz-linux-zen.EFI
[Install]
WantedBy=multi-user.target
Systemd Service Unit:
#/etc/systemd/system/zfsbootsync.service
[Unit]
Description=Sync /boot to ZFS mirror
[Service]
Type=oneshot
ExecStart=/usr/local/bin/zfsbootsync
With that done, I can move on to creating an ISO to boot from that will have the recovery script, ssh keys to connect to the remote server with, and a few other QoL changes like networking, a root password, creating a user and a sudoers file, disabling auto-login, and installing the following packages:
- zfs
- networkmanager
- ssh
- rsync
- gptfdisk
- parted
Since this client is on arch linux we will be using https://wiki.archlinux.org/title/Archiso. With this, all additional files I wish to be added to the ISO will be added to the airootfs folder. Once that is completed, I can create the ISO with the following command, which will copy the recovery script into the airootfs folder, create the iso, and burn it onto a usb drive using dd.
sudo cp <RECOVERY SCRIPT> airootfs/home/archie/ && sudo rm -rf isobuild && \
sudo mkarchiso -v -r -w work -o isobuild . && \
sudo dd bs=4M if=isobuild/archlinux-2026.04.16-x86_64.iso \
of=/dev/disk/by-id/<USB DRIVE> conv=fsync oflag=direct status=progress
Creating & testing the recovery script
Now that we have all the prep work done, we can get to writing the recovery script itself. This is the actual purpose of, and easily the most complex part of this project. But, once it is done, a certain level of peace of mind will be had in knowing that barring physical catastrophe, all of my unrecoverable data is protected. Here is a list I made before writing it of the process it should take:
- Connect to remote server
- Verify with user what datasets to recover
- Pick a snapshot time stamp
- Store dataset attributes
- Format disk and create new zpool/boot partition
- Use zfs send/receive to send data over
- Set temp mount points under /mnt
- Mount copied over partitions
- Sync /boot/efi with zfsboot partition
- Configure chroot environment of target
- Set dataset attributes
- Unmount partitions
- Export zfs pool
After a lot of trial and error testing this on my laptop, here is the script I landed on:
#!/usr/bin/env bash
set -xeo pipefail
# Config
#
DEVNAME="/dev/nvme0n1"
SRCSRV="<IP>" #Server to pull recovery from
SSHKEYFILE="/home/archie/.ssh/id_ed25519"
SSHUSER="root" #zfs send req
WIPEZFSPART="true"
RESCUEEFI="true" #WIPES WHOLE DRIVE!!!
RESCUEROOT="true"
RESCUEHOME="true"
SNAPSHOTNAME="zrepl_2026-04-18_04:14:59" #zfs list -t snapshot
# Check for nvme drive
if [[ "$DEVNAME" =~ nvme ]]; then
DESTDEV="${DEVNAME}p"
else
DESTDEV="$DEVNAME"
fi
validate() {
echo "=== VALIDATION ==="
# Does device exist?
[[ -b "$DEVNAME" ]] || {
echo "ERROR: bad device"
exit 1
}
# Does snapshot exist?
ssh -i $SSHKEYFILE $SSHUSER@$SRCSRV "zfs list -H <REMOTE SNAPSHOT PATH>@${SNAPSHOTNAME}" >/dev/null 2>&1 || {
echo "SNAPSHOTNAME is empty"
exit 1
}
# Print confirmation info
lsblk "$DEVNAME"
echo
echo "ZFS partition wipe: $WIPEZFSPART"
echo "EFI restore: $RESCUEEFI"
echo "ROOT restore: $RESCUEROOT"
echo "HOME restore: $RESCUEHOME"
echo "Snapshot: $SNAPSHOTNAME"
echo "Source: $SSHUSER@$SRCSRV"
echo
if [[ "$RESCUEEFI" == true ]]; then
echo "WILL CREATE: EFI partition (512M)"
echo "!!! THIS WILL WIPE: $DEVNAME"
WIPEZFSPART=true
RESCUEHOME=true
RESCUEROOT=true
fi
if [[ "$WIPEZFSPART" == true ]]; then
echo "WILL CREATE: ZFS partition (rest of disk)"
echo "!!! THIS WILL WIPE: $DEVNAME ZFS PARTITION"
fi
echo
if [[ "$WIPEZFSPART" == true && "$RESCUEEFI" == true ]]; then
echo "!!! THIS WILL WIPE: $DEVNAME"
fi
}
confirm() {
# Confirm destructive task
read -rp "Type 'WIPE' to continue: " ans
[[ "$ans" == "WIPE" ]] || exit 1
}
destruct() {
echo "=== DESTRUCTION ==="
# Reformat EFI partition
if [[ "$RESCUEEFI" == true ]]; then
sgdisk --zap-all "$DEVNAME"
sgdisk -n 1:0:+512M "$DEVNAME"
sgdisk -t 1:EF00 "$DEVNAME"
fi
# Reformat zfs partition
if [[ "$WIPEZFSPART" == true ]]; then
sgdisk -n 2:0:0 "$DEVNAME"
sgdisk -t 2:BF00 "$DEVNAME"
fi
}
setup_fs() {
echo "=== FS SETUP ==="
if [[ "$RESCUEEFI" == true ]]; then
mkfs.fat -F32 "${DESTDEV}1"
fi
if [[ "$WIPEZFSPART" == true ]]; then
zpool create -f -o ashift=12 \
-O compression=lz4 \
-O acltype=posixacl \
-O xattr=sa \
-O atime=off \
-O encryption=aes-256-gcm \
-O keyformat=passphrase \
-O keylocation=prompt \
-O normalization=formD \
-o autotrim=on \
-m none zroot "${DESTDEV}2"
fi
}
restore_root() {
if [[ "$RESCUEROOT" == true || "$RESCUEEFI" == true ]]; then
echo "RESTORE ROOT"
# Create system dataset zroot/ROOT if doesnt exist
if ! zfs list -H "zroot/ROOT" >/dev/null 2>&1; then
zfs create zroot/ROOT
zfs set mountpoint=none zroot/ROOT
zfs set canmount=off zroot/ROOT
fi
# Receive dataset zroot/ROOT/arch
if ! ssh -i "$SSHKEYFILE" \
$SSHUSER@$SRCSRV \
"set -euo pipefail; zfs send <REMOTE SNAPSHOT PATH>@${SNAPSHOTNAME}" |
zfs receive -F -u zroot/ROOT/arch; then
echo "ERROR: root restore failed"
zfs destroy -r zroot/ROOT/arch || true
exit 1
fi
fi
}
restore_home() {
if [[ "$RESCUEHOME" == true || "$RESCUEEFI" == true ]]; then
echo "RESTORE HOME"
# Create system dataset zroot/data if it doesnt exist
if ! zfs list -H "zroot/data" >/dev/null 2>&1; then
zfs create zroot/data
zfs set mountpoint=none zroot/data
zfs set canmount=off zroot/data
fi
if ! zfs list -H "zroot/data/home" >/dev/null 2>&1; then
zfs create zroot/data/home
zfs set mountpoint=none zroot/data/home
zfs set canmount=off zroot/data/home
fi
# Receive dataset zroot/data/home
if ! ssh -i "$SSHKEYFILE" \
"$SSHUSER@$SRCSRV" \
"set -euo pipefail; zfs send <REMOTE SNAPSHOT PATH>@${SNAPSHOTNAME}" |
zfs receive -F -u zroot/data/home/<USER>; then
echo "ERROR: home/<USER> restore failed"
zfs destroy -r zroot/data/home/<USER> || true
exit 1
fi
zfs load-key -r zroot
fi
}
restore_boot() {
[[ "$RESCUEEFI" != true ]] && return
echo "RESTORE BOOT"
# Create system dataset zroot/data if it doesnt exist
if ! zfs list -H "zroot/data" >/dev/null 2>&1; then
zfs create zroot/data
zfs set mountpoint=none zroot/data
zfs set canmount=off zroot/data
fi
# Receive dataset zroot/data/zfsboot
if ! ssh -i "$SSHKEYFILE" \
"$SSHUSER@$SRCSRV" \
"set -euo pipefail; zfs send <REMOTE SNAPSHOT PATH>@${SNAPSHOTNAME}" |
zfs receive -F -u zroot/data/zfsboot; then
echo "ERROR: boot restore failed"
zfs destroy -r zroot/data/zfsboot || true
exit 1
fi
# Load encryption key and mount drive
zfs load-key -r zroot
# Set mountpoint for /, /zfsboot, /boot/efi into /mnt
zfs set mountpoint=/mnt zroot/ROOT/arch
zfs set mountpoint=/mnt/zfsboot zroot/data/zfsboot
zfs set canmount=on zroot/data/zfsboot
zfs set canmount=on zroot/ROOT/arch
mkdir -p /mnt/boot/efi
mount ${DESTDEV}1 /mnt/boot/efi
# Sync /zfsboot to /boot
rsync -aHAX --delete \
--exclude='loader/random-seed' \
/mnt/zfsboot \
/mnt/boot
}
chroot_config() {
echo "CONFIGURING CHROOT"
# Get /boot/efi partuuid
local fstab="/mnt/etc/fstab"
local new_uuid=$(blkid -s PARTUUID -o value "${DESTDEV}1")
# Verify part uuid exists
if [[ -z "$new_uuid" ]]; then
echo "ERROR: Failed to get PARTUUID for ${DESTDEV}1" >&2
return 1
fi
# Copy over new partuuid to fstab
sed -i "\|/boot/efi| s|\(/dev/disk/by-partuuid/\)[^[:space:]]*|\1${new_uuid}|" "$fstab"
if ! grep -q "$new_uuid" "$fstab"; then
echo "ERROR: UUID not written correctly" >&2
return 1
fi
echo "fstab updated successfully with PARTUUID=$new_uuid"
echo "Backup created: $backup"
arch-chroot /mnt rm /etc/zfs/zpool.cache || true
arch-chroot /mnt rm /etc/hostid
cp /etc/zfs/zpool.cache /mnt/etc/zfs/zpool.cache
arch-chroot /mnt zgenhostid
arch-chroot /mnt zfs set org.zfsbootmenu:commandline="noresume init_on_alloc=0 rw spl.spl_hostid=$(hostid) splash" zroot/ROOT/arch
arch-chroot /mnt refind-install
arch-chroot /mnt mkinitcpio -P
arch-chroot /mnt generate-zbm
}
setopts_export() {
umount /mnt/boot/efi || true
zfs umount zroot/data/zfsboot || true
zfs umount zroot/ROOT/arch || true
# Set dataset properties
zpool set cachefile=/etc/zfs/zpool.cache zroot
zfs set canmount=noauto zroot/ROOT/arch
zfs set mountpoint=/ zroot/ROOT/arch
zfs set mountpoint=/zfsboot zroot/data/zfsboot
zfs set canmount=on zroot/data/zfsboot
zfs set mountpoint=/home zroot/data/home
zfs set canmount=on zroot/data/home
zfs set mountpoint=/home/<USER> zroot/data/home/<USER>
zfs set canmount=on zroot/data/home/<USER>
zpool export zroot
}
main() {
validate
confirm
destruct
setup_fs
restore_root
restore_boot
restore_home
chroot_config
setopts_export
}
main "$@"
And it works! Running this in a livecd on my laptop allowed me to reboot into a functionally identical system to my workstation!
Insights & Reflections
One of the biggest issues I had during this project was during testing. I had the idea for what the script should do, but I would be overlooking small steps, or lose internet connection halfway through and have to restart. A lot of times this resulted in me making changes to the ISO on my workstation, re-burning it onto a USB, and restarting the process of copying over my system. Could take a few hours just to get one test done. What helped me about halfway through was creating a main function and just commenting out the successful steps from previous attempts.
For larger projects like this, that deal with large quantities of data, I might have to look into test-driven development in order to make the finishing tweaks on a script like this quicker to accomplish. The final quirk I’m dealing with is that on the target machine, it asks for the encryption key twice during boot. I’m sure it will be an easy fix once I track down the cause. Moving forward I would like to fix that final issue, and find a way to resume a previously in progress rescue attempt.