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 calledzroot, with two datasets:zroot/rootandzroot/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-utilsupgrades. 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.confis 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/hostnamecontains a bareartixinstead ofhostname="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/homewasn’t mounted whenuseradd -mran, your home directory ends up hidden underneath the ZFS mount and login complains there’s no home. If/home/yourusernameis 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
zfshook loads the module itself, so do not addzfstoMODULES, it’s redundant. And do not reach for thesystemdhook 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_CRYPTODISKneeded here, since/bootis 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/homemounts itself via ZFS’scanmount=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 demandzfs_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:
- UEFI loads GRUB off the unencrypted ESP
- GRUB loads
vmlinuz-linux-zenand the initramfs - The
encrypthook asks for your LUKS passphrase and opens/dev/mapper/cryptroot - The
zfshook importszrootand mountszroot/rootas/ - OpenRC takes over,
zroot/homemounts 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:
zfs-dkms+ Artixlinux-zenis the default and works as long as OpenZFS supports the kernelzfs-linux-zen+ Archlinux-zenmeans installing the Arch kernel that matches the prebuilt ZFS packagezfs-linux-lts+ Archlinux-ltsas the last resort, since LTS moves slowly and mismatches are rare
Using
zfs-dkmsmay 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 likelinux-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 :)