Installing Artix on ZFS on LUKS2


In my last post I talked about on about why I left Arch for Artix, and I closed it off by promising a writeup on the actual installation. So here it is. Only slightly later than the “soon” I promised, but who was actually reading this and counting the days lol.

Quick recap of what we’re building, because the layout matters and half the decisions later only make sense once you see the whole picture:

  • GRUB as the bootloader, on an unencrypted ESP
  • LUKS2 with the default Argon2id KDF wrapping the root partition
  • ZFS as the only real filesystem on the box, sitting on top of the LUKS container
  • OpenRC as init, which is the whole reason I’m here in the first place :)
  • linux-zen for the kernel
  • nftables for the firewall
    One pool called zroot, with two datasets: zroot/root and zroot/home. They share the pool’s free space, which (as I went on about last time) was one of my favorite reasons for going all in on ZFS. No more guessing how big the root partition needs to be and then quietly regretting it six months later.

Standard disclaimer: this is what worked on my machine, not gospel. Not the official Artix way, not the official ZFS way, just my way. Back up anything you care about before you start wiping partitions. You’ve been warned.

I’m using /dev/nvme0n1 the whole way through. Change it to whatever your disk actually is (for SATA drives it usually is /dev/sdX), and read every command before you run it. The sgdisk and cryptsetup ones in particular do not ask twice.

The disk layout

Partition Size Type Filesystem Encrypted
nvme0n1p1 1 GiB EF00 (ESP) FAT32 No
nvme0n1p2 Remainder 8309 (Linux LUKS) LUKS2 then ZFS Yes

Boot stays unencrypted on the ESP, everything else lives inside LUKS. There’s a nice payoff to that split which I’ll get to in Phase 4.


Phase 1: Boot the live ISO

Grab the Artix base OpenRC ISO and boot it. At the boot menu pick “From CD/DVD/ISO”, not “From Stick/HDD”. The second one may cause polkit issues you do not want to be debugging at this stage.

Log in with the default credentials which are displayed on the screen and get networking up. If you’re wired with DHCP, just confirm you’re online:

ping -c3 artixlinux.org

On wifi, use connmanctl:

connmanctl
> enable wifi
> scan wifi
> services
> agent on
> connect wifi_<TAB>
> quit

The ISO ships a mirrorlist that might be stale, so pull a fresh one:

curl -o /etc/pacman.d/mirrorlist "https://packages.artixlinux.org/mirrorlist/all/https/"

Optionally rank by speed (takes a minute or two):

cp /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak
rankmirrors -n 5 /etc/pacman.d/mirrorlist.bak > /etc/pacman.d/mirrorlist

Worth doing this again inside the chroot later (Phase 8) so the installed system starts with a fresh, ranked list too. Same URL works for both.


Phase 2: Get ZFS into the live environment

ZFS isn’t on the ISO, so we add the archzfs experimental repo and install it into the running live system first.

cat >> /etc/pacman.conf << 'EOF'
[archzfs]
SigLevel = Required
Server = https://github.com/archzfs/archzfs/releases/download/experimental
EOF
 
pacman-key --init
pacman-key --recv-keys 3A9917BF0DED5C13F69AC68FABEC0A1208037BE9
pacman-key --lsign-key 3A9917BF0DED5C13F69AC68FABEC0A1208037BE9
 
pacman -Sy

Install it and load the module:

pacman -S linux-zen-headers zfs-dkms zfs-utils
modprobe zfs

If modprobe zfs comes back clean, you’re good to keep going. Here zfs-dkms makes sense, since the ISO doesn’t ship with the zen-kernel


Phase 3: Partition the disk

lsblk                              # find your target disk
sgdisk --zap-all /dev/nvme0n1      # wipe the existing table
 
sgdisk -n1:0:+1G   -t1:EF00 -c1:"ESP"  /dev/nvme0n1
sgdisk -n2:0:0     -t2:8309 -c2:"LUKS" /dev/nvme0n1
 
sgdisk -p /dev/nvme0n1             # verify

Phase 4: Create the LUKS2 container

Here’s the payoff I mentioned. Because /boot lives on the unencrypted ESP, GRUB never has to touch the LUKS container at all. The encrypt hook in the initramfs does the unlocking instead. That means full LUKS2 with Argon2id works out of the box. No fighting GRUB over which key derivation function it does or doesn’t support, which is a headache I was very happy to skip.

cryptsetup luksFormat --type luks2 /dev/nvme0n1p2
cryptsetup open /dev/nvme0n1p2 cryptroot

If you’re on an SSD and want TRIM passthrough:

cryptsetup --allow-discards --persistent refresh cryptroot

Phase 5: Create the ZFS pool and datasets

The pool itself. The flags are mostly standard ZFS sanity defaults (ashift=12, xattr=sa, acltype=posixacl, zstd compression, and so on):

zpool create -f \
    -o ashift=12 \
    -o autotrim=on \
    -O acltype=posixacl \
    -O relatime=on \
    -O xattr=sa \
    -O dnodesize=auto \
    -O normalization=formD \
    -O mountpoint=none \
    -O canmount=off \
    -O compression=zstd \
    -R /mnt \
    zroot /dev/mapper/cryptroot

Then the two datasets. This is the part I actually care about, root and home sharing one pool:

zfs create -o mountpoint=/ -o canmount=noauto zroot/root
zfs create -o mountpoint=/home zroot/home

Mount root and check it landed:

zfs mount zroot/root
mount | grep zroot

Set the boot filesystem so ZFS knows what to boot into:

zpool set bootfs=zroot/root zroot

Phase 6: Format and mount the ESP

mkfs.vfat -F32 -n ESP /dev/nvme0n1p1
mkdir -p /mnt/boot
mount /dev/nvme0n1p1 /mnt/boot

Phase 7: Install the base system

basestrap /mnt base base-devel openrc elogind-openrc \
    linux-zen linux-zen-headers linux-firmware \
    grub efibootmgr os-prober \
    cryptsetup cryptsetup-openrc device-mapper-openrc \
    nftables nftables-openrc \
    vim nano dosfstools

You can drop os-prober if you don’t plan on dualbooting

Generate the fstab:

fstabgen -U /mnt >> /mnt/etc/fstab

Now open /mnt/etc/fstab and delete any zroot lines. ZFS manages its own mounts, and leaving those entries in will only cause issues. The only thing that should survive is the ESP:

# /dev/nvme0n1p1
UUID=XXXX-XXXX  /boot  vfat  rw,relatime,fmask=0022,dmask=0022  0 2

Phase 8: Install ZFS into the new system

Add the same archzfs repo to the installed system:

cat >> /mnt/etc/pacman.conf << 'EOF'
[archzfs]
SigLevel = Required
Server = https://github.com/archzfs/archzfs/releases/download/experimental
EOF

Chroot in:

artix-chroot /mnt /bin/bash

Import the key and install:

pacman-key --init
pacman-key --populate artix
pacman-key --recv-keys 3A9917BF0DED5C13F69AC68FABEC0A1208037BE9
pacman-key --lsign-key 3A9917BF0DED5C13F69AC68FABEC0A1208037BE9
 
pacman -Sy zfs-linux-zen zfs-utils

One small annoyance to deal with right away. zfs-utils drops a /etc/modules-load.d/zfs.conf that tries to load ZFS at boot, except the initramfs already loaded it, so you get a “Module already in kernel” warning every single time. Just remove it:

rm /etc/modules-load.d/zfs.conf

Heads up, this file can come back on zfs-utils upgrades. If the warning reappears later, delete it again. Mildly irritating but harmless.


Phase 9: System configuration

Timezone and locale, nothing surprising here:

ln -sf /usr/share/zoneinfo/Region/City /etc/localtime
hwclock --systohc
 
# uncomment your locale(s) in /etc/locale.gen
locale-gen
echo "LANG=en_US.UTF-8" > /etc/locale.conf

Now the OpenRC specific stuff, which is where coming from systemd-land will trip you up if you’re not paying attention.

Keymap goes in /etc/conf.d/keymaps, not /etc/vconsole.conf:

echo 'keymap="de-latin1"' > /etc/conf.d/keymaps
rc-update add keymaps boot

/etc/vconsole.conf is a systemd thing. OpenRC ignores it completely, so don’t bother.

Hostname is the one that got me. It has to be a variable assignment, not a bare string:

echo "artix" > /etc/hostname
echo 'hostname="artix"' > /etc/conf.d/hostname
 
cat > /etc/hosts << EOF
127.0.0.1   localhost
::1         localhost
127.0.1.1   artix.localdomain artix
EOF

If /etc/conf.d/hostname contains a bare artix instead of hostname="artix", OpenRC tries to run it as a command and throws a “command not found” at boot. Ask me how I know.

Root password, then a user:

passwd
 
useradd -m -G wheel -s /bin/bash yourusername
passwd yourusername
 
EDITOR=vim visudo
# uncomment: %wheel ALL=(ALL:ALL) ALL

Quick gotcha for later: if zroot/home wasn’t mounted when useradd -m ran, your home directory ends up hidden underneath the ZFS mount and login complains there’s no home. If /home/yourusername is missing after first boot:

sudo mkdir /home/yourusername
sudo chown yourusername:yourusername /home/yourusername
sudo chmod 700 /home/yourusername
cp -a /etc/skel/. /home/yourusername/
sudo chown -R yourusername:yourusername /home/yourusername

Phase 10: Configure mkinitcpio

This is the bit that makes the whole encrypt-then-ZFS chain actually work, so the hook order is not optional. Edit /etc/mkinitcpio.conf:

MODULES=()
 
HOOKS=(base udev autodetect microcode modconf kms keyboard keymap consolefont block encrypt zfs filesystems)

The order does the work: encrypt prompts for the LUKS passphrase and opens /dev/mapper/cryptroot, then zfs imports the pool and mounts zroot/root. Swap those two and nothing will boot.

The zfs hook loads the module itself, so do not add zfs to MODULES, it’s redundant. And do not reach for the systemd hook either. The ZFS initramfs hook is busybox based and the two don’t mix.

Build it:

mkinitcpio -P

Phase 11: Configure GRUB

Grab the LUKS partition UUID first:

blkid /dev/nvme0n1p2     # the one with TYPE="crypto_LUKS"

Put it in /etc/default/grub:

GRUB_CMDLINE_LINUX="cryptdevice=UUID=<your-luks-uuid>:cryptroot zfs=zroot/root"

No GRUB_ENABLE_CRYPTODISK needed here, since /boot is unencrypted. That’s the upside of the layout again.

Install and generate:

grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=Artix --recheck
grub-mkconfig -o /boot/grub/grub.cfg

If grub-mkconfig dies with “failed to get canonical path”, it’s a known GRUB/ZFS bug. The symlink dance fixes it:

ln -s /dev/mapper/cryptroot /dev/cryptroot
grub-mkconfig -o /boot/grub/grub.cfg

Phase 12: ZFS cache and host ID

zpool set cachefile=/etc/zfs/zpool.cache zroot
zgenhostid $(hostid)

Phase 13: OpenRC services

rc-update add device-mapper boot
rc-update add dmcrypt boot
rc-update add keymaps boot

The pool gets imported and root gets mounted by the initramfs, and zroot/home mounts itself via ZFS’s canmount=on. So there are no custom ZFS OpenRC services to add. One of those nice cases where the simple setup is also the correct one.


Phase 14: nftables firewall

rc-update add nftables default

Then /etc/nftables.conf:

#!/usr/sbin/nft -f
 
flush ruleset
 
table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;
 
        ct state established,related accept
        iif lo accept
        meta l4proto icmp accept
        meta l4proto icmpv6 accept
 
        # Uncomment to allow SSH:
        # tcp dport 22 accept
 
        log prefix "[nftables drop] " drop
    }
 
    chain forward {
        type filter hook forward priority filter; policy drop;
    }
 
    chain output {
        type filter hook output priority filter; policy accept;
    }
}

This is just a basic nftables config to get things started, feel free to extend this later on. nftables is super powerful


Phase 15: Networking

Pick one. ConnMan:

pacman -S connman connman-openrc
rc-update add connmand default

Or NetworkManager:

pacman -S networkmanager networkmanager-openrc
rc-update add NetworkManager default

Phase 16: Final checks and reboot

Before you leave the chroot, run through this. It’s saved me from a non-booting system more than once:

# initramfs actually has the encrypt and zfs hooks
lsinitcpio /boot/initramfs-linux-zen.img | grep -E "zfs|encrypt"
 
# GRUB config has the right kernel params
grep -E "cryptdevice|zfs=" /boot/grub/grub.cfg
 
# cachefile exists
ls -la /etc/zfs/zpool.cache
 
# kernel is present
ls /boot/vmlinuz-linux-zen
 
# host id is set
hostid
 
# the annoying duplicate-module file is gone
ls /etc/modules-load.d/zfs.conf 2>/dev/null && echo "WARNING: remove this file" || echo "OK"

Then exit and export cleanly:

exit
 
umount /mnt/boot
 
zfs unmount zroot/home
zfs unmount zroot/root
zpool export zroot

If the export refuses to let go:

umount -l /mnt/home
umount -l /mnt
zpool export -f zroot
reboot

Do not skip the clean zpool export. If you do, the pool import on first boot can fail or demand zfs_force=1, which is a bad start to a new system.


What actually happens at boot

So you can picture the chain you just built:

  1. UEFI loads GRUB off the unencrypted ESP
  2. GRUB loads vmlinuz-linux-zen and the initramfs
  3. The encrypt hook asks for your LUKS passphrase and opens /dev/mapper/cryptroot
  4. The zfs hook imports zroot and mounts zroot/root as /
  5. OpenRC takes over, zroot/home mounts itself, services come up
    That’s it. Encrypt, then ZFS, then init. Clean and predictable, which is exactly what I wanted out of this whole move.

The ZFS and kernel upgrade question

This is the part everyone warns you about, and fair enough, ZFS being an out-of-tree module is the one genuine tax you pay for using it. If a new major kernel lands before OpenZFS supports it, you wait. I said in the last post this is a trade I’m happy to make, and I stand by that, but you do have to be a little deliberate about upgrades.

Although Artix’s linux-zen may lag slightly behind Arch’s, the version should always match, so zfs-linux-zen is the sane default.

If DKMS ever lets you down, here’s the fallback chain:

  1. zfs-dkms + Artix linux-zen is the default and works as long as OpenZFS supports the kernel
  2. zfs-linux-zen + Arch linux-zen means installing the Arch kernel that matches the prebuilt ZFS package
  3. zfs-linux-lts + Arch linux-lts as the last resort, since LTS moves slowly and mismatches are rare

Using zfs-dkms may lead to a failed generation of the initramfs and a kernel panic on boot if the kernel version is not yet supported by zfs, so it is wise to have a backup kernel like linux-lts, in case that happens:


Troubleshooting

The greatest hits, in case something goes sideways:

“cannot open ‘zroot’: no such pool” at boot. The encrypt hook isn’t running before zfs, or your cryptdevice= is wrong. Drop to the initramfs shell and do it by hand:

cryptsetup open /dev/nvme0n1p2 cryptroot
zpool import -R /new_root zroot
exit

GRUB: “failed to get canonical path”. Known GRUB/ZFS bug. The symlink workaround from Phase 11.

"/home/user: No such file or directory" at login. zroot/home is mounted, but your home got created on the root dataset during install. The fix is in the Phase 9 blockquote.

“Module already in kernel” at boot. Delete /etc/modules-load.d/zfs.conf. Not a huge problem, it’s just a warning, but just delete this file, it’ll keep you company forever otherwise.

“command not found” hostname error at boot. /etc/conf.d/hostname needs hostname="artix", not a bare artix.

Pool export fails before reboot. Unmount the datasets first, then force the export if you have to:

zfs unmount zroot/home
zfs unmount zroot/root
zpool export zroot
 
# if that still won't budge:
umount -l /mnt/home
umount -l /mnt
zpool export -f zroot

Key files, for future me

File What it’s for
/etc/mkinitcpio.conf initramfs hooks (encrypt, zfs)
/etc/default/grub GRUB kernel parameters
/etc/zfs/zpool.cache pool import cache, baked into the initramfs
/etc/hostid ZFS host identifier
/etc/nftables.conf firewall rules
/etc/conf.d/hostname hostname, OpenRC style: hostname="name"
/etc/conf.d/keymaps keymap, OpenRC style: keymap="de-latin1"
/etc/modules-load.d/zfs.conf delete it, causes the duplicate module warning

And that’s the install. Encrypted LUKS2, ZFS on top, OpenRC running the show, not a trace of systemd. It took a bit of fiddling to get the hook order and the OpenRC quirks right, but now that it’s done it just works, and it’s mine in a way Arch hadn’t quite been for a while.

If you hit something I didn’t cover, the usual rule applies: read the error, check the hook order, and make sure that zfs.conf file is still dead. Have fun :)