Install Ubuntu on ZFS with ZFSBootMenu
Contents
Tested on: Ubuntu 26.04 LTS (Resolute), x86_64, UEFI
This guide installs a minimal Ubuntu system directly on ZFS. It supports three root-pool layouts:
| Layout | Minimum disks | Disk failures tolerated | Rough RAID equivalent |
|---|---|---|---|
| Mirror | 2 | 1 | RAID1 |
| RAIDZ1 | 3 | 1 | RAID5 |
| RAIDZ2 | 4 | 2 | RAID6 |
The installation flow is the same for every layout. Only the zpool create
command changes.
The procedure follows the official ZFSBootMenu Ubuntu UEFI guide, adapted for redundant pools and an EFI System Partition (ESP) on every disk.
Warning: The disk preparation commands destroy partition tables and all data on the selected disks. Verify every device name with
lsblkbefore continuing.
Assumptions
- the machine boots in UEFI mode;
- Ubuntu 26.04 live or server installation media is used;
- all disks in the pool have the same size;
- examples use
/dev/vda,/dev/vdb,/dev/vdc, and/dev/vdd; - the root pool is named
zroot; - encryption is not enabled in this example.
For a physical installation, prefer stable paths from /dev/disk/by-id/ when
creating the pool. Device names such as /dev/sda can change between boots.
Prepare the live environment
Open a root shell and confirm that the live system was booted in UEFI mode:
sudo -i
test -d /sys/firmware/efi/efivars
dmesg | grep -i efivarsInstall the required tools and create a host ID:
source /etc/os-release
export ID
apt update
apt install --yes debootstrap gdisk zfsutils-linux
HOST_ID=$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')
zgenhostid -f "0x${HOST_ID}"
hostid1. Partition the disks
Each disk uses the same GPT layout:
| Partition | Size | Type | Purpose |
|---|---|---|---|
| 1 | 1 GiB | EF00 |
EFI System Partition |
| 2 | Remaining space | BF00 |
ZFS root pool member |
The example below partitions only /dev/vda:
DISK=/dev/vda
wipefs --all "$DISK"
sgdisk --zap-all "$DISK"
# 1 GiB EFI System Partition.
sgdisk --new=1:1MiB:+1GiB --typecode=1:EF00 \
--change-name=1:'EFI System Partition' "$DISK"
# Leave a small amount of free space at the end of the disk.
sgdisk --new=2:0:-10MiB --typecode=2:BF00 \
--change-name=2:'ZFS root pool' "$DISK"
partprobe "$DISK"
udevadm settle
sgdisk --print "$DISK"
lsblk --fs "$DISK"Repeat these commands for every disk that will participate in the selected
topology. For example, a RAIDZ2 pool needs the same two partitions on vda,
vdb, vdc, and vdd.
For NVMe devices, partition paths contain an extra p: /dev/nvme0n1p1 and
/dev/nvme0n1p2. The examples below use virtio-style paths such as /dev/vda2.
2. Create the ZFS pool
Define the common pool options once:
ZPOOL_OPTIONS=(
-f
-o ashift=12
-o autotrim=on
-o compatibility=openzfs-2.1-linux
-O compression=lz4
-O acltype=posixacl
-O xattr=sa
-O relatime=on
-m none
)Choose exactly one of the following three layouts.
Option 1: two-disk mirror
A mirror stores a complete copy on both disks. It can survive the failure of either disk and is the simplest layout for a small system.
zpool create "${ZPOOL_OPTIONS[@]}" zroot mirror \
/dev/vda2 \
/dev/vdb2Option 2: three-disk RAIDZ1
RAIDZ1 uses single parity. With three disks it provides approximately the capacity of two disks and survives one disk failure.
zpool create "${ZPOOL_OPTIONS[@]}" zroot raidz1 \
/dev/vda2 \
/dev/vdb2 \
/dev/vdc2Option 3: four-disk RAIDZ2
RAIDZ2 uses double parity. With four disks it provides approximately the capacity of two disks and survives any two simultaneous disk failures.
zpool create "${ZPOOL_OPTIONS[@]}" zroot raidz2 \
/dev/vda2 \
/dev/vdb2 \
/dev/vdc2 \
/dev/vdd2Verify the selected topology before installing anything:
zpool status
zpool get ashift,autotrim,compatibility zrootThe output must show mirror-0, raidz1-0, or raidz2-0, depending on the
chosen option.
Create the datasets
Create a dataset hierarchy for the root filesystem and home directories:
zfs create -o mountpoint=none zroot/ROOT
zfs create -o mountpoint=/ -o canmount=noauto "zroot/ROOT/${ID}"
zfs create -o mountpoint=/home zroot/home
zpool set bootfs="zroot/ROOT/${ID}" zroot
zpool get bootfs zroot
zfs get canmount,mountpoint zroot/ROOT "zroot/ROOT/${ID}" zroot/homeExport the pool and import it with /mnt as a temporary root:
zpool export zroot
zpool import -N -R /mnt zroot
zfs mount "zroot/ROOT/${ID}"
zfs mount zroot/home
mount | grep '/mnt'
udevadm trigger3. Install Ubuntu with debootstrap
Confirm that the live environment’s debootstrap package knows the resolute
suite. This is expected when using Ubuntu 26.04 installation media:
test -e /usr/share/debootstrap/scripts/resoluteIf this check fails, use current Ubuntu 26.04 live or server installation media instead of trying to bootstrap the new release with an outdated suite script.
Install the Resolute base system into the mounted root dataset:
debootstrap resolute /mnt http://archive.ubuntu.com/ubuntu/
cp /etc/hostid /mnt/etc/hostid
cp /etc/resolv.conf /mnt/etc/resolv.conf
mount -t proc proc /mnt/proc
mount -t sysfs sys /mnt/sys
mount --rbind /dev /mnt/dev
mount --make-rslave /mnt/dev
mount --rbind /run /mnt/run
mount --make-rslave /mnt/run
chroot /mnt /bin/bashAll commands in the following sections run inside the chroot.
Configure the base system
Set the hostname and configure APT. Ubuntu 24.04 and newer use the deb822 format
in /etc/apt/sources.list.d/ubuntu.sources by default:
HOSTNAME=zfs-host
echo "$HOSTNAME" > /etc/hostname
printf '127.0.1.1\t%s\n' "$HOSTNAME" >> /etc/hosts
rm -f /etc/apt/sources.list
mkdir -p /etc/apt/sources.list.d
cat > /etc/apt/sources.list.d/ubuntu.sources <<'EOF'
Types: deb
URIs: http://archive.ubuntu.com/ubuntu/
Suites: resolute resolute-updates resolute-backports
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
Types: deb
URIs: http://security.ubuntu.com/ubuntu/
Suites: resolute-security
Components: main restricted universe multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
EOF
# The live environment may export en_US.UTF-8 before that locale exists in
# the minimal chroot. Use the built-in C.UTF-8 locale during bootstrap.
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
apt update
apt install --yes locales
sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
locale-gen
update-locale LANG=en_US.UTF-8
unset LC_ALL
export LANG=en_US.UTF-8
locale
apt upgrade --yesInstall the kernel, ZFS integration, networking, SSH, and basic administration tools:
apt install --yes --no-install-recommends \
linux-generic \
zfs-initramfs \
zfsutils-linux \
dosfstools \
efibootmgr \
curl \
tzdata \
keyboard-configuration \
console-setup \
netplan.io \
openssh-server \
sudo
dpkg-reconfigure tzdata keyboard-configuration console-setup
passwdCreate an administrative user if needed:
useradd --create-home --shell /bin/bash --groups sudo admin
passwd adminEnable ZFS services and build the initramfs:
systemctl enable zfs.target
systemctl enable zfs-import-cache
systemctl enable zfs-mount
systemctl enable zfs-import.target
systemctl enable ssh
zfs set org.zfsbootmenu:commandline='quiet' zroot/ROOT
update-initramfs -c -k allInstall ZFSBootMenu on every disk
List every disk used by the selected topology. Keep the order stable: the first
disk will provide the ESP mounted at /boot/efi during normal operation.
# Mirror:
BOOT_DISKS=(/dev/vda /dev/vdb)
# RAIDZ1 instead:
# BOOT_DISKS=(/dev/vda /dev/vdb /dev/vdc)
# RAIDZ2 instead:
# BOOT_DISKS=(/dev/vda /dev/vdb /dev/vdc /dev/vdd)Use a helper that handles both /dev/vda1 and /dev/nvme0n1p1 naming:
partition_path() {
local disk=$1
local number=$2
if [[ $disk =~ [0-9]$ ]]; then
printf '%sp%s\n' "$disk" "$number"
else
printf '%s%s\n' "$disk" "$number"
fi
}Format every ESP, copy the ZFSBootMenu EFI image, install the portable fallback path, and create a firmware boot entry:
mountpoint --quiet /sys/firmware/efi/efivars || \
mount -t efivarfs efivarfs /sys/firmware/efi/efivars
mkdir -p /boot/efi
for index in "${!BOOT_DISKS[@]}"; do
disk=${BOOT_DISKS[$index]}
esp=$(partition_path "$disk" 1)
mkfs.vfat -F32 "$esp"
mount "$esp" /boot/efi
mkdir -p /boot/efi/EFI/ZBM /boot/efi/EFI/BOOT
curl -L https://get.zfsbootmenu.org/efi \
-o /boot/efi/EFI/ZBM/VMLINUZ.EFI
cp /boot/efi/EFI/ZBM/VMLINUZ.EFI \
/boot/efi/EFI/ZBM/VMLINUZ-BACKUP.EFI
cp /boot/efi/EFI/ZBM/VMLINUZ.EFI \
/boot/efi/EFI/BOOT/BOOTX64.EFI
efibootmgr --create --disk "$disk" --part 1 \
--label "ZFSBootMenu $((index + 1))" \
--loader '\EFI\ZBM\VMLINUZ.EFI'
umount /boot/efi
doneThe EFI/BOOT/BOOTX64.EFI copy provides a standard fallback path for firmware
that loses or ignores custom NVRAM boot entries.
Add only the first ESP to /etc/fstab and mount it:
PRIMARY_ESP=$(partition_path "${BOOT_DISKS[0]}" 1)
ESP_UUID=$(blkid -s UUID -o value "$PRIMARY_ESP")
printf 'UUID=%s /boot/efi vfat umask=0077 0 2\n' "$ESP_UUID" \
>> /etc/fstab
mount /boot/efi
findmnt /boot/efi
efibootmgr --verboseConfigure networking
Interface names vary between physical machines and virtual environments. Check the available interfaces:
ip -brief linkCreate a minimal DHCP configuration, replacing enp1s0 with the correct
interface:
cat > /etc/netplan/01-system.yaml <<'EOF'
network:
version: 2
ethernets:
enp1s0:
dhcp4: true
EOF
chmod 600 /etc/netplan/01-system.yaml
netplan generateInstall zbm-esp-sync
ZFS provides redundancy for the pool but does not synchronize the independent
FAT32 EFI System Partitions. Install
zbm-esp-sync before leaving the
chroot so later ZFSBootMenu updates can be copied to every disk.
Download a tagged release from the GitLab Generic Package Registry. Change
VERSION when installing a newer release:
VERSION=v0.1.0
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64|arm64) ;;
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac
PACKAGE="zbm-esp-sync_${VERSION}_linux_${ARCH}"
ARCHIVE="${PACKAGE}.tar.gz"
PACKAGE_URL="https://gitlab.com/api/v4/projects/tty8747%2Fzbm-esp-sync/packages/generic/zbm-esp-sync/${VERSION}"
cd /tmp
curl --fail --location --remote-name "${PACKAGE_URL}/${ARCHIVE}"
curl --fail --location --remote-name "${PACKAGE_URL}/${ARCHIVE}.sha256"
sha256sum --check "${ARCHIVE}.sha256"
tar --extract --gzip --file "$ARCHIVE"Install the static binary and systemd units:
install -D -m 0755 "/tmp/${PACKAGE}/zbm-esp-sync" \
/usr/local/sbin/zbm-esp-sync
install -D -m 0644 "/tmp/${PACKAGE}/systemd/zbm-esp-sync.service" \
/etc/systemd/system/zbm-esp-sync.service
install -D -m 0644 "/tmp/${PACKAGE}/systemd/zbm-esp-sync.path" \
/etc/systemd/system/zbm-esp-sync.path
zbm-esp-sync --versionCreate the configuration from the ESP UUIDs. The first disk in BOOT_DISKS is
the master mounted at /boot/efi; all remaining ESPs are backup targets:
install -d -m 0700 /etc/zbm-esp-sync
MASTER_ESP=$(partition_path "${BOOT_DISKS[0]}" 1)
MASTER_UUID=$(blkid -s UUID -o value "$MASTER_ESP")
{
printf 'master: /dev/disk/by-uuid/%s\n' "$MASTER_UUID"
printf 'esp:\n'
for disk in "${BOOT_DISKS[@]}"; do
esp=$(partition_path "$disk" 1)
uuid=$(blkid -s UUID -o value "$esp")
printf ' - /dev/disk/by-uuid/%s\n' "$uuid"
done
printf 'paths:\n'
printf ' - EFI/ZBM\n'
printf ' - EFI/BOOT\n'
} > /etc/zbm-esp-sync/config.yaml
chmod 600 /etc/zbm-esp-sync/config.yaml
cat /etc/zbm-esp-sync/config.yamlVerify that all ESPs contain identical files and preview the first refresh:
zbm-esp-sync list
zbm-esp-sync status
zbm-esp-sync verify
zbm-esp-sync refresh --dry-runEnable the path unit. Do not use --now inside the chroot because its systemd
instance is not running yet:
systemctl enable zbm-esp-sync.pathAfter the first boot, confirm that the watcher and the last synchronization job are healthy:
systemctl status zbm-esp-sync.path
journalctl -u zbm-esp-sync.serviceFinish the installation
Exit the chroot, recursively unmount the temporary filesystem tree, export the pool, and reboot:
exit
umount --no-mtab --recursive /mnt
zpool export zroot
rebootAfter booting, verify the pool and the root dataset:
zpool status
zfs list
findmnt /Configure Docker to use ZFS
If this machine will run Docker, prepare its storage before installing Docker Engine or pulling any images. A separate dataset keeps Docker data isolated and allows the daemon to use ZFS snapshots and clones for image and container layers:
sudo zfs create \
-o mountpoint=/var/lib/docker \
-o atime=off \
zroot/docker
sudo install -d -m 0755 /etc/docker
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"features": {
"containerd-snapshotter": false
},
"storage-driver": "zfs",
"storage-opts": [
"zfs.fsname=zroot/docker"
]
}
EOFDocker Engine 29 and later enables the containerd image store by default on
fresh installations. Disabling containerd-snapshotter here selects the
classic storage-driver architecture required by Docker’s zfs driver. Install
Docker Engine using your preferred package source, then restart it and verify
the effective configuration:
sudo systemctl restart docker
docker info --format 'Storage driver: {{.Driver}}'
docker info | grep -E 'Storage Driver|Zpool|Parent Dataset'
zfs list -r zroot/dockerThe expected storage driver is zfs, with zroot/docker as its parent
dataset. Configure this before creating containers: switching storage backends
later makes existing local images and containers unavailable until the previous
backend is restored. Docker recommends a dedicated pool on dedicated block
devices for demanding production workloads; a child dataset in zroot is a
practical configuration for a single-host installation.
For PostgreSQL, virtual-machine image files, and databases running in containers, create dedicated datasets with properties appropriate for their small random-I/O workload. For example:
sudo zfs create \
-o recordsize=16K \
-o compression=lz4 \
-o atime=off \
zroot/postgresqlMount or bind-mount that dataset directly into the database container instead
of storing the database in the container’s writable layer. The ideal record
size depends on the application and should be validated with the real workload.
For a VM stored in a ZVOL, configure volblocksize when creating the volume;
recordsize applies to filesystems and VM disk-image files.
Recovery notes
If the firmware does not show the new boot entries, select an EFI shell or the firmware’s Boot from file action and launch one of these paths from any ESP:
EFI\ZBM\VMLINUZ.EFI
EFI\BOOT\BOOTX64.EFIThe pool topology does not change the recovery procedure. ZFSBootMenu imports
zroot, reads its bootfs property, and starts the kernel from the selected
boot environment.