Contents

Install Ubuntu on ZFS with ZFSBootMenu

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 lsblk before 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:

1
2
3
4
sudo -i

test -d /sys/firmware/efi/efivars
dmesg | grep -i efivars

Install the required tools and create a host ID:

1
2
3
4
5
6
7
8
9
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}"
hostid

1. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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.

1
2
3
zpool create "${ZPOOL_OPTIONS[@]}" zroot mirror \
  /dev/vda2 \
  /dev/vdb2

Option 2: three-disk RAIDZ1

RAIDZ1 uses single parity. With three disks it provides approximately the capacity of two disks and survives one disk failure.

1
2
3
4
zpool create "${ZPOOL_OPTIONS[@]}" zroot raidz1 \
  /dev/vda2 \
  /dev/vdb2 \
  /dev/vdc2

Option 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.

1
2
3
4
5
zpool create "${ZPOOL_OPTIONS[@]}" zroot raidz2 \
  /dev/vda2 \
  /dev/vdb2 \
  /dev/vdc2 \
  /dev/vdd2

Verify the selected topology before installing anything:

1
2
zpool status
zpool get ashift,autotrim,compatibility zroot

The 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:

1
2
3
4
5
6
7
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/home

Export the pool and import it with /mnt as a temporary root:

1
2
3
4
5
6
7
zpool export zroot
zpool import -N -R /mnt zroot
zfs mount "zroot/ROOT/${ID}"
zfs mount zroot/home

mount | grep '/mnt'
udevadm trigger

3. 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:

1
test -e /usr/share/debootstrap/scripts/resolute

If 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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/bash

All 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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 --yes

Install the kernel, ZFS integration, networking, SSH, and basic administration tools:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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
passwd

Create an administrative user if needed:

1
2
useradd --create-home --shell /bin/bash --groups sudo admin
passwd admin

Enable ZFS services and build the initramfs:

1
2
3
4
5
6
7
8
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 all

Install 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.

1
2
3
4
5
6
7
8
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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
done

The 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:

1
2
3
4
5
6
7
8
9
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 --verbose

Configure networking

Interface names vary between physical machines and virtual environments. Check the available interfaces:

1
ip -brief link

Create a minimal DHCP configuration, replacing enp1s0 with the correct interface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cat > /etc/netplan/01-system.yaml <<'EOF'
network:
  version: 2
  ethernets:
    enp1s0:
      dhcp4: true
EOF

chmod 600 /etc/netplan/01-system.yaml
netplan generate

Install 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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:

1
2
3
4
5
6
7
8
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 --version

Create 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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.yaml

Verify that all ESPs contain identical files and preview the first refresh:

1
2
3
4
zbm-esp-sync list
zbm-esp-sync status
zbm-esp-sync verify
zbm-esp-sync refresh --dry-run

Enable the path unit. Do not use --now inside the chroot because its systemd instance is not running yet:

1
systemctl enable zbm-esp-sync.path

After the first boot, confirm that the watcher and the last synchronization job are healthy:

1
2
systemctl status zbm-esp-sync.path
journalctl -u zbm-esp-sync.service

Finish the installation

Exit the chroot, recursively unmount the temporary filesystem tree, export the pool, and reboot:

1
2
3
4
5
exit

umount --no-mtab --recursive /mnt
zpool export zroot
reboot

After booting, verify the pool and the root dataset:

1
2
3
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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"
  ]
}
EOF

Docker 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:

1
2
3
4
5
sudo systemctl restart docker

docker info --format 'Storage driver: {{.Driver}}'
docker info | grep -E 'Storage Driver|Zpool|Parent Dataset'
zfs list -r zroot/docker

The 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:

1
2
3
4
5
sudo zfs create \
  -o recordsize=16K \
  -o compression=lz4 \
  -o atime=off \
  zroot/postgresql

Mount 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:

1
2
EFI\ZBM\VMLINUZ.EFI
EFI\BOOT\BOOTX64.EFI

The pool topology does not change the recovery procedure. ZFSBootMenu imports zroot, reads its bootfs property, and starts the kernel from the selected boot environment.