| pick your distro, get ZFS on root
kldload — your platform, your way, free
Source

Migrating to kldload

Every migration follows the same pattern: backup the old system, install kldload, restore the data. The details change depending on what you are migrating from and what you are migrating. This guide covers all of it.

The migration philosophy: you are not "converting" your existing system. You are building a clean, ZFS-rooted system and moving your data and config into it. Clean installs are always more reliable than in-place upgrades. kldload makes clean installs fast enough that this is the right call every time.

Every sysadmin who's been through a major migration knows the temptation: "can I just upgrade in place?" The answer is almost always no, and when it's yes, you wish you hadn't. In-place upgrades carry forward years of accumulated drift — orphaned packages, deprecated config syntax, permissions that were hacked to fix a one-off problem in 2019. A clean install on ZFS gives you a known-good baseline. Your data moves over via rsync or ZFS send. Your configs get cherry-picked, not bulk-copied. What you end up with is a system you actually understand, not a system that "sort of works because nobody touched that config file since the last guy left." The migration is the cleanup. Don't skip it.

From ext4/btrfs to ZFS

The idea

You cannot convert an ext4 or btrfs filesystem to ZFS in place. You need a second disk (or enough free space on a second partition), install kldload there, then move your data over. Think of it like moving house — you cannot renovate the foundation while living in the building.

analogy: you are moving into a new house with a better foundation (ZFS). Pack your boxes, move, unpack.
You cannot convert ext4 or btrfs to ZFS. Full stop. ZFS manages the entire block device — it's a volume manager AND a filesystem. ext4 sits on top of a partition that sits on top of an LVM volume (maybe). They're completely different storage models. Trying to "convert" one to the other is like trying to turn a house into a boat. But here's the thing: this is actually good news. A clean install means you start with a proper ZFS layout — datasets for /home, /var, /opt, each with their own compression and snapshot policies. You can't get that from a conversion. You get it from a clean install where ZFS builds the pool from scratch.

Step 1 — Inventory your data

# What filesystems do you have?
df -hT

# How much data?
du -sh /home /var/lib /opt /srv /etc 2>/dev/null

# What services are running?
systemctl list-units --type=service --state=running

Step 2 — Full backup to external drive

# Mount your backup drive
mount /dev/sdb1 /mnt/backup

# Backup everything that matters
rsync -aAXv --progress \
  --exclude='/dev/*' \
  --exclude='/proc/*' \
  --exclude='/sys/*' \
  --exclude='/tmp/*' \
  --exclude='/run/*' \
  --exclude='/mnt/*' \
  --exclude='/media/*' \
  --exclude='/lost+found' \
  / /mnt/backup/old-system/

# Backup package list for reference
rpm -qa --qf '%{NAME}\n' | sort > /mnt/backup/old-system/rpm-packages.txt
# or for Debian:
dpkg --get-selections | awk '{print $1}' > /mnt/backup/old-system/deb-packages.txt

Step 3 — Install kldload

# Boot the kldload USB
# Select your target distro (CentOS, Debian, or RHEL)
# Install to the disk — ZFS on root is automatic
# Reboot into your new system
The rsync exclude list is important. /dev, /proc, /sys, /run are virtual filesystems — they don't contain real data, they're generated at boot. /tmp is temporary. /mnt and /media are mount points. Copying any of these will either waste space or break things. The package list export (rpm -qa or dpkg --get-selections) is your insurance policy — it tells you exactly what was installed so you can reinstall on the new system without guessing. Do this before you touch anything.

Step 4 — Restore data

# Mount the backup drive on the new system
mount /dev/sdb1 /mnt/backup

# Restore home directories
rsync -aAXv /mnt/backup/old-system/home/ /home/

# Restore application data
rsync -aAXv /mnt/backup/old-system/var/lib/postgresql/ /var/lib/postgresql/
rsync -aAXv /mnt/backup/old-system/opt/ /opt/
rsync -aAXv /mnt/backup/old-system/srv/ /srv/

# Restore select configs (NOT all of /etc — cherry-pick what you need)
cp -a /mnt/backup/old-system/etc/ssh/ssh_host_* /etc/ssh/
cp -a /mnt/backup/old-system/etc/crontab /etc/crontab
cp -a /mnt/backup/old-system/etc/fstab.custom /etc/  # if you had custom mounts

From bare metal

Side-by-side installation

If your server has two disks (or free space for a second partition), you can install kldload alongside the existing system and migrate data live — no downtime for the backup step. Boot the kldload USB, install to the second disk, then rsync from the old disk while both are mounted.

analogy: building the new house next door, then carrying your furniture across the yard.

Two-disk migration (preferred)

# Boot kldload USB
# Install to /dev/sdb (leave /dev/sda alone — that is your old system)
# Reboot into kldload on /dev/sdb

# Mount the old disk
mkdir -p /mnt/old
mount /dev/sda2 /mnt/old    # adjust partition number

# Rsync data directly — no external backup needed
rsync -aAXv --progress /mnt/old/home/ /home/
rsync -aAXv --progress /mnt/old/var/lib/ /var/lib/
rsync -aAXv --progress /mnt/old/etc/ssh/ssh_host_* /etc/ssh/

# When satisfied, wipe the old disk and add it to your ZFS pool
wipefs -a /dev/sda
zpool attach rpool /dev/sdb2 /dev/sda2   # mirror the root pool
# Now you have ZFS mirrored across both disks
The two-disk migration is the best possible path because the old system is still intact until you're completely done. You can boot back into it at any time by changing the boot order in BIOS. The final step — wiping the old disk and attaching it as a ZFS mirror — means you end the migration with a redundant pool instead of a single-disk system. You went from one disk with ext4 and no redundancy to two disks with ZFS mirroring, snapshots, and checksums. The migration itself improved your storage reliability.

Single-disk migration

# You must backup to an external drive first (see ext4/btrfs section above)
# Then install kldload to the same disk (destructive — wipes the disk)
# Then restore from backup

From VMs

VM migration pattern

Do not try to convert a VM image to ZFS. Create a fresh kldload VM, then move your data into it. The VM is disposable — your data is what matters.

analogy: do not renovate the rental. Move to the better apartment and bring your stuff.

From VMware / VirtualBox / Hyper-V

# On the OLD VM — export your data
tar czf /tmp/app-data.tar.gz /home /var/lib/myapp /etc/myapp /srv
scp /tmp/app-data.tar.gz user@new-host:/tmp/

# On the NEW kldload VM — import
cd /
tar xzf /tmp/app-data.tar.gz

# Reinstall your application packages
dnf install -y postgresql-server nginx   # CentOS/RHEL
# or
apt install -y postgresql nginx          # Debian
The "do not convert, create fresh and move data" rule applies double for VMs. VM disk images are opaque blobs — a vmdk or qcow2 contains a filesystem layout that made sense for the original OS. Trying to mount it, resize partitions, and install ZFS inside it is a recipe for a corrupted disk image and a wasted afternoon. Instead: create a clean kldload VM with a fresh ZFS-rooted disk, then rsync or scp your data from the old VM to the new one. The old VM stays running until you're satisfied. The cutover is a DNS change or IP swap.

From Proxmox

# On the Proxmox host — take a backup
vzdump 100 --storage local --mode snapshot

# Create a new kldload VM in Proxmox
# Boot the kldload ISO, install
# Then restore data from the old VM backup or rsync from the old VM directly:
rsync -aAXv --progress root@old-vm:/home/ /home/
rsync -aAXv --progress root@old-vm:/var/lib/ /var/lib/
rsync -aAXv --progress root@old-vm:/srv/ /srv/

From KVM / libvirt

# List your existing VMs
virsh list --all

# Create a new kldload VM (or use deploy.sh kvm-deploy)
# Boot, install kldload

# Mount the old VM disk image to copy data out
guestmount -a /var/lib/libvirt/images/old-vm.qcow2 -m /dev/sda2 /mnt/old
rsync -aAXv /mnt/old/home/ /home/
guestunmount /mnt/old

From another distro

Distro-to-distro migration

kldload supports CentOS Stream 9, Debian 13 (Trixie), and RHEL 9. If you are on an older version of one of these (or Ubuntu), you are effectively doing a major version upgrade plus moving to ZFS. Do them together with a clean install.

analogy: if you are remodeling the kitchen anyway, this is the time to fix the foundation too.
Distro migrations are where most people get into trouble. CentOS 7 to CentOS Stream 9 is effectively two major version jumps — different systemd version, different default crypto policies, different package names, different Python version. An in-place upgrade through two major versions has a near-zero success rate on production systems. The clean install path means you get CentOS Stream 9 with all the right defaults, and you reinstall only the packages that still exist. The old package list comparison script below tells you exactly what will and won't carry forward. Anything that's gone (python2, old library versions) was a dependency you need to replace anyway.

CentOS 7/8 to kldload CentOS 9

# CentOS 7/8 is EOL. Do NOT attempt an in-place upgrade.

# Inventory what you have installed
rpm -qa --qf '%{NAME}\n' | sort > /tmp/old-packages.txt

# Backup your data and config
rsync -aAXv /home /var/lib /etc /srv /opt /tmp/migration-backup/

# Install kldload (select CentOS Stream 9)
# Restore data (see steps above)

# Reinstall packages that still exist in CentOS 9
# Compare your old package list against dnf:
while read pkg; do
  dnf list available "$pkg" &>/dev/null && echo "$pkg"
done < /tmp/old-packages.txt > /tmp/installable.txt

dnf install -y $(cat /tmp/installable.txt)

Debian 11/12 to kldload Debian 13

# Same pattern — backup data, install fresh, restore
dpkg --get-selections | awk '{print $1}' > /tmp/old-packages.txt

# Backup data
tar czf /tmp/migration-data.tar.gz /home /var/lib /etc /srv

# Install kldload (select Debian)
# Restore data
# Reinstall packages:
while read pkg; do
  apt-cache show "$pkg" &>/dev/null 2>&1 && echo "$pkg"
done < /tmp/old-packages.txt > /tmp/installable.txt

xargs apt install -y < /tmp/installable.txt

Ubuntu to kldload

# Ubuntu packages are mostly compatible with Debian.
# Choose kldload Debian 13 as your target.

# Note what Ubuntu-specific PPAs you use
ls /etc/apt/sources.list.d/

# Backup your data (same pattern)
# Install kldload Debian 13
# Most packages from Ubuntu main/universe exist in Debian.
# PPA packages (like some NVIDIA drivers) will need manual reinstallation.

Database migration

PostgreSQL on ZFS

ZFS is excellent for databases. The key is setting the right recordsize for your workload. PostgreSQL uses 8K pages, so recordsize=8k on the data dataset eliminates read amplification. WAL logs get their own dataset with recordsize=64k for sequential write throughput.

analogy: recordsize is like choosing the right box size for what you are packing. Books go in small boxes. Pillows go in big boxes.
This is where ZFS pays for itself on day one. On ext4, PostgreSQL has to enable full_page_writes to protect against torn pages during a crash. This doubles the WAL write volume — every modified page gets written in full to the WAL before the actual data file is updated. On ZFS, this is unnecessary because ZFS is copy-on-write: a write either completes atomically or it doesn't happen. There are no torn pages. Setting full_page_writes = off on ZFS immediately reduces your WAL I/O by roughly 50%. The separate datasets for data (recordsize=8k) and WAL (recordsize=64k) match PostgreSQL's own I/O patterns — 8K random reads for data, 64K sequential writes for WAL. You're not just migrating your database, you're moving it to storage that understands how databases work.

PostgreSQL — pg_dump and pg_restore

# === ON THE OLD SERVER ===

# Dump all databases (custom format for parallel restore)
pg_dumpall -U postgres > /tmp/all-databases.sql

# Or dump a specific database in custom format (faster restore)
pg_dump -U postgres -Fc mydb > /tmp/mydb.dump

# Transfer to new server
scp /tmp/mydb.dump root@new-server:/tmp/
# === ON THE NEW kldload SERVER ===

# Create ZFS datasets with optimal recordsize
zfs create -o recordsize=8k -o primarycache=metadata \
  -o logbias=throughput rpool/postgres-data
zfs create -o recordsize=64k rpool/postgres-wal

# Install PostgreSQL
dnf install -y postgresql-server   # CentOS/RHEL
# or
apt install -y postgresql           # Debian

# Point PostgreSQL at the ZFS datasets
mkdir -p /rpool/postgres-data/pgdata /rpool/postgres-wal/pg_wal
chown postgres:postgres /rpool/postgres-data/pgdata /rpool/postgres-wal/pg_wal

# Initialize with custom WAL directory
sudo -u postgres initdb \
  -D /rpool/postgres-data/pgdata \
  --waldir=/rpool/postgres-wal/pg_wal

# Update postgresql.conf
cat >> /rpool/postgres-data/pgdata/postgresql.conf <<'PGCONF'
data_directory = '/rpool/postgres-data/pgdata'
full_page_writes = off          # ZFS is copy-on-write, no torn pages
wal_init_flags = ''
PGCONF

# Start and restore
systemctl start postgresql

# Restore all databases
sudo -u postgres psql < /tmp/all-databases.sql

# Or restore a specific database
sudo -u postgres createdb mydb
pg_restore -U postgres -d mydb /tmp/mydb.dump

MySQL / MariaDB

# === ON THE OLD SERVER ===
mysqldump --all-databases --single-transaction > /tmp/all-mysql.sql
scp /tmp/all-mysql.sql root@new-server:/tmp/

# === ON THE NEW kldload SERVER ===
zfs create -o recordsize=16k -o primarycache=metadata rpool/mysql-data

dnf install -y mariadb-server   # CentOS/RHEL

# Point datadir at ZFS
systemctl stop mariadb
rsync -aAXv /var/lib/mysql/ /rpool/mysql-data/
sed -i 's|datadir=.*|datadir=/rpool/mysql-data|' /etc/my.cnf.d/mariadb-server.cnf
systemctl start mariadb

mysql < /tmp/all-mysql.sql

Docker migration

Docker on ZFS

kldload can run Docker with the ZFS storage driver. Every container layer and volume becomes a ZFS dataset. You get snapshots, checksums, and compression for free. The migration path: export your images and volumes from the old system, install kldload, configure the ZFS Docker driver, import everything.

analogy: moving your shipping containers to a port with better cranes and a warehouse that never loses inventory.

Export from the old system

# Save all images
docker images --format '{{.Repository}}:{{.Tag}}' | while read img; do
  filename=$(echo "$img" | tr '/:' '_')
  docker save "$img" -o "/tmp/docker-images/${filename}.tar"
done

# Backup named volumes
docker volume ls --format '{{.Name}}' | while read vol; do
  docker run --rm -v "${vol}:/data" -v /tmp/docker-volumes:/backup \
    alpine tar czf "/backup/${vol}.tar.gz" -C /data .
done

# Export docker-compose files
find / -name 'docker-compose*.yml' -exec cp {} /tmp/docker-compose/ \; 2>/dev/null

# Transfer everything
rsync -aAXv /tmp/docker-images/ root@new-server:/tmp/docker-images/
rsync -aAXv /tmp/docker-volumes/ root@new-server:/tmp/docker-volumes/
rsync -aAXv /tmp/docker-compose/ root@new-server:/tmp/docker-compose/
Docker on ZFS is a genuine upgrade over overlay2 on ext4. Every container layer becomes a ZFS clone — a zero-cost snapshot. docker commit is instant because it's a ZFS snapshot, not a copy. Container volumes on ZFS get checksumming, compression, and snapshot capability that overlay2 simply doesn't have. The ZFS storage driver also eliminates the "overlay2 running out of inodes" problem that plagues high-density container hosts. The migration is worth doing even if you have no other reason to move to ZFS.

Import on kldload

# Create ZFS dataset for Docker
zfs create -o mountpoint=/var/lib/docker rpool/docker

# Install Docker
dnf install -y docker-ce docker-ce-cli containerd.io   # CentOS/RHEL
# or
apt install -y docker-ce docker-ce-cli containerd.io    # Debian

# Configure ZFS storage driver
mkdir -p /etc/docker
cat > /etc/docker/daemon.json <<'EOF'
{
  "storage-driver": "zfs"
}
EOF

systemctl enable --now docker

# Load images
for img in /tmp/docker-images/*.tar; do
  docker load -i "$img"
done

# Restore volumes
for vol in /tmp/docker-volumes/*.tar.gz; do
  volname=$(basename "$vol" .tar.gz)
  docker volume create "$volname"
  docker run --rm -v "${volname}:/data" -v /tmp/docker-volumes:/backup \
    alpine tar xzf "/backup/${volname}.tar.gz" -C /data
done

# Start your compose stacks
cd /tmp/docker-compose
docker compose up -d

Config migration

Do not copy all of /etc

Copying the entire /etc from an old system to a new one will break things. Different distro versions have different config formats, different default users, different systemd units. Cherry-pick the configs you actually customized. Leave everything else at the new system defaults.

analogy: you would not take the wiring from your old house and staple it into the new one. You bring your furniture, not the plumbing.
This is the section that separates a professional migration from a cargo-cult copy. Bulk-copying /etc is the most common migration mistake. The old /etc/fstab references partitions that don't exist on ZFS. The old /etc/default/grub has ext4-specific boot parameters. The old /etc/security/limits.conf might have values tuned for a different kernel. The old /etc/sysctl.conf might conflict with ZFS's own tunables. Cherry-pick: SSH host keys (so clients don't get fingerprint warnings), user accounts, cron jobs, and application-specific configs. Everything else should come from the new OS defaults. If you don't know whether a config file should be migrated, it shouldn't.

User accounts

# On the old system — extract human users (UID >= 1000)
awk -F: '$3 >= 1000 && $3 < 65534 {print $0}' /etc/passwd > /tmp/users-passwd.txt
awk -F: '$3 >= 1000 && $3 < 65534 {print $0}' /etc/shadow > /tmp/users-shadow.txt
awk -F: '$3 >= 1000 && $3 < 65534 {print $0}' /etc/group  > /tmp/users-group.txt

# On the new system — append (do NOT overwrite)
cat /tmp/users-passwd.txt >> /etc/passwd
cat /tmp/users-shadow.txt >> /etc/shadow
cat /tmp/users-group.txt  >> /etc/group

# Restore home directories
rsync -aAXv /mnt/backup/home/ /home/

SSH keys

# Host keys — keeps the server fingerprint the same (no "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED")
cp -a /mnt/backup/etc/ssh/ssh_host_* /etc/ssh/
chmod 600 /etc/ssh/ssh_host_*_key
chmod 644 /etc/ssh/ssh_host_*_key.pub
systemctl restart sshd

# User keys come with home directory restore
# Authorized keys are in ~/.ssh/authorized_keys (already restored with /home)

Cron jobs

# System crontab
cp /mnt/backup/etc/crontab /etc/crontab

# User crontabs
cp -a /mnt/backup/var/spool/cron/crontabs/* /var/spool/cron/crontabs/ 2>/dev/null  # Debian
cp -a /mnt/backup/var/spool/cron/* /var/spool/cron/ 2>/dev/null                     # CentOS/RHEL

# Systemd timers (modern replacement for cron)
ls /mnt/backup/etc/systemd/system/*.timer
# Copy any custom timer/service pairs you created
cp /mnt/backup/etc/systemd/system/my-backup.* /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now my-backup.timer

Firewall rules

# kldload uses nftables by default

# If coming from iptables — export and translate
iptables-save > /tmp/old-iptables.rules
iptables-restore-translate -f /tmp/old-iptables.rules > /tmp/new-nftables.rules
# Review the translated rules, then apply:
nft -f /tmp/new-nftables.rules

# If coming from firewalld — it still works on kldload CentOS
cp -a /mnt/backup/etc/firewalld/ /etc/firewalld/
systemctl restart firewalld

# If coming from ufw (Ubuntu) — translate to nftables manually
# ufw rules are in /etc/ufw/user.rules — read and recreate with nft

DNS / network cutover

Zero-downtime switchover

If the old server and new server are on the same network, the cleanest cutover is: bring up the new server with a temporary IP, verify everything works, then swap IPs. Total downtime: the time it takes to change two IP addresses (seconds, not minutes).

analogy: set up the new restaurant across the street, get it running perfectly, then swap the signs overnight.
The zero-downtime cutover is the reward for doing the migration properly. Old server stays running the whole time. New server is fully configured and tested on a temporary IP. Data is rsynced while both are live. The actual cutover is two nmcli commands and a gratuitous ARP — total downtime measured in seconds. The DNS TTL trick (lower TTL 48 hours in advance) means even DNS-based cutovers resolve within a minute. Compare this to the "shut down, clone disk, boot on new hardware, pray" approach that most people use. The parallel setup means you can abort at any point and the old server is untouched.

Same IP switchover

# === PREPARATION (both servers running) ===

# New server uses a temporary IP during setup
# Old server: 192.168.1.100 (the production IP)
# New server: 192.168.1.200 (temporary)

# Do all your data migration while both are up
rsync -aAXv root@192.168.1.100:/home/ /home/
rsync -aAXv root@192.168.1.100:/var/lib/ /var/lib/

# === CUTOVER (brief downtime) ===

# On the OLD server — remove the production IP
nmcli con mod ens18 ipv4.addresses ""
nmcli con down ens18

# On the NEW server — take the production IP
nmcli con mod ens18 ipv4.addresses "192.168.1.100/24"
nmcli con mod ens18 ipv4.gateway "192.168.1.1"
nmcli con up ens18

# Send a gratuitous ARP to update the network
arping -U -c 3 -I ens18 192.168.1.100

# === DONE — clients reconnect automatically ===

Same hostname

# Set the hostname on the new system
hostnamectl set-hostname myserver.example.com

# If using DNS — update the A record to point to the new IP
# (or keep the same IP as above, and DNS stays the same)

# If using /etc/hosts on other machines — no change needed if IP is the same

DNS cutover with TTL trick

# 48 hours BEFORE migration — lower the DNS TTL
# In your DNS provider, change the TTL from 3600 (1 hour) to 60 (1 minute)
# Wait 48 hours for the old TTL to expire everywhere

# During migration — update the DNS A record to the new IP
# Clients will pick up the change within 60 seconds

# After migration — raise TTL back to 3600

Verification checklist

What to check after migration

Do not declare victory until you have verified everything. This checklist is the difference between a smooth migration and a 3am page.

analogy: the moving company dropped off your boxes. Now walk through every room and make sure nothing is broken.
The verification checklist is not optional. Every migration that skips verification eventually becomes a 3am incident. The reboot test at the end is the most important step — services that start because you manually started them are not the same as services that start because they're enabled in systemd. If you don't reboot and verify, you'll find out what's not enabled the hard way: when the server reboots for a kernel update at 2am and half your services don't come back. The post-migration ZFS snapshot (rpool@post-migration) is your rollback point. If anything goes wrong in the first week, you can roll back to exactly this state.

System basics

# ZFS pool is healthy
zpool status

# Boot environment exists
zfs list -t snapshot | head -20

# Correct hostname and IP
hostnamectl
ip addr show

# Time is synced
timedatectl

# Disk space is reasonable
zfs list
df -h

Services

# All expected services are running
systemctl list-units --type=service --state=running

# Check specific critical services
systemctl status sshd
systemctl status postgresql    # if applicable
systemctl status docker        # if applicable
systemctl status nginx         # if applicable

# No failed units
systemctl --failed

Data integrity

# Run a ZFS scrub to verify all checksums
zpool scrub rpool
zpool status rpool   # watch for errors

# Verify database connectivity
sudo -u postgres psql -c "SELECT count(*) FROM pg_database;"

# Verify file counts match
find /home -type f | wc -l        # compare with old system
find /var/lib/myapp -type f | wc -l

Networking

# Can reach the internet
ping -c 3 1.1.1.1

# DNS resolution works
dig example.com

# Other hosts can reach this server
# (from another machine)
ssh root@myserver.example.com "hostname"

# Firewall rules are active
nft list ruleset | head -40
# or
firewall-cmd --list-all

Backups and replication

# If using sanoid/syncoid — verify snapshot policy is active
systemctl status sanoid.timer
sanoid --configcheck

# ZFS replication is working (if configured)
syncoid --dryrun rpool/data remote-server:backup/data

# Take a post-migration snapshot as your baseline
zfs snapshot -r rpool@post-migration-$(date +%Y%m%d)

Final sign-off

# Reboot and verify everything comes back
reboot

# After reboot — check that all services started automatically
systemctl --failed
zpool status
docker ps           # if using Docker
systemctl status postgresql  # if using PostgreSQL