Docs → ZFS on /home or a data drive
Keep btrfs, put ZFS where your data is
Your Omarchy install stays exactly as it is: btrfs root, snapper,
limine, upstream's tested boot path. ZFS goes underneath your files instead:
/home, a photo array, a scratch pool, whatever you replicate to a
NAS. No reinstall, and it is straightforward to undo.
Contents
- What you get, and what you give up
- Install the ZFS module
- Install omarchy-zfs (and what it does here)
- Create the pool
- Move /home onto it, carefully
- Make it import and mount at boot
- Encryption and where the key lives
- Snapshots, scrubs, replication
- Kernel upgrades: the one real cost
- When /home does not show up
What you get, and what you give up
| ZFS on /home (this page) | ZFS root (route 1) | |
|---|---|---|
| Reinstall needed | No | Yes |
| Snapshots of your files | Yes | Yes |
| Roll back the OS from the boot menu | No. That stays btrfs/snapper | Yes, via ZFSBootMenu boot environments |
zfs send replication | Yes, for your data | Yes, for everything |
| Passphrase prompts at boot | Two, if you encrypt the pool (pool + login). Or use PAM unlock | One |
| Reversible | Easily | Reinstall |
| Exposure to kernel/ZFS skew | Yes. /home is missing if the module fails to build | Yes. The machine will not boot |
If you only want your data on ZFS, stop here and do this. Go for a ZFS root if you want the boot-time recovery: an older kernel and initramfs paired with the older userland, and a shell with the pool imported before anything starts. See the comparison.
1. Install the ZFS module
ZFS is out-of-tree, so on Arch you pick a strategy. This is the part people get wrong.
| Option | What it is | Use when |
|---|---|---|
zfs-dkms + zfs-utilsarchzfs repo or AUR |
Rebuilds the module for each kernel you install. | The released OpenZFS supports your kernel. |
zfs-dkms-git + zfs-utils-gitAUR |
Same, from OpenZFS master. | Arch's kernel is newer than the last OpenZFS release. Often the case. This is what our ISO and the author's workstation run. |
zfs-linux / zfs-linux-ltsarchzfs |
Prebuilt module pinned to one exact kernel build. | You run linux-lts and want no compiler in the loop. Be aware
the pin means kernel and module must move together, and archzfs can lag
badly. |
# The combination most likely to build on a current Arch kernel:
yay -S zfs-dkms-git zfs-utils-git
sudo modprobe zfs
zfs version
If the module does not build, pacman prints a warning and
carries on. Nothing stops you rebooting into a kernel with no
zfs.ko. The usual cause is missing kernel headers:
neither zfs-dkms nor zfs-dkms-git declares
them, because neither can know which kernel you boot. Install the set
matching your kernel (linux-headers,
linux-lts-headers, and so on).
Then confirm the module actually exists before you reboot:
omarchy-zfs-module-check # checks every installed kernel
modinfo -k $(uname -r) zfs # or ask modprobe directly
omarchy-zfs-module-check also runs from a pacman hook after any
kernel or DKMS transaction, so a silent build failure gets reported at the
moment it happens rather than at the next reboot.
2. Install omarchy-zfs, and know what it does here
yay -S omarchy-zfs
The boot-safety guards are built for root-on-ZFS and stay inert on a btrfs
root: each checks the root filesystem and exits when it is not ZFS. Two tools
do work here regardless, and are the reason to install it on this layout:
omarchy-zfs-boot-mounts-setup and
omarchy-zfs-module-check.
| Component | On a btrfs root with a ZFS data pool |
|---|---|
omarchy-zfs-ensure-mkinitcpio | No-op. Your initramfs and Omarchy's mkinitcpio drop-in are left alone. |
omarchy-zfs-snapper-guard | No-op. Your working snapper configs are untouched. It only removes configs pointing at something that is not btrfs, and only on a ZFS root. |
omarchy-zfs-bootorder-guard | No-op. It never touches your EFI boot order; limine stays first. |
omarchy-zfs-autosnap | No-op. Pre-upgrade snapshots are not automatic in this layout. See snapshots. |
omarchy-zfs-boot-mounts-setup | Active. Opts pools into zfs-mount-generator so datasets mount and keys load at boot. |
omarchy-zfs-module-check | Active. Warns when an installed kernel has no zfs.ko, naming the headers package to install. |
omarchy-zfs-scrub.timer | Active. Monthly scrub of every imported pool. |
omarchy-zfs-kernel-compat-check | Active (1.1.3+). Blocks a kernel upgrade that zfs-dkms cannot build against, whenever ZFS is in use. Not only on ZFS roots. See below. |
omarchy-bootstrap-zfs, omarchy-refresh-zbm, ZFSBootMenu configs | Installed but unused. They are for ZFS roots. |
You can skip the package entirely and manage ZFS by hand. You then own the kernel-skew problem and the scrub schedule yourself.
3. Create the pool
Always address disks by stable path. /dev/sda is not stable
across reboots:
ls -l /dev/disk/by-id/ | grep -v part
# Single disk. Everything on it is destroyed.
sudo zpool create -o ashift=12 -o autotrim=on \
-O compression=zstd -O atime=off -O relatime=on \
-O xattr=sa -O acltype=posixacl -O dnodesize=auto \
-O mountpoint=none \
tank /dev/disk/by-id/ata-YOUR_DISK_ID
Two disks you want to survive one failure:
sudo zpool create ... tank mirror /dev/disk/by-id/disk-A /dev/disk/by-id/disk-B
ashift=12 matches 4K sectors and cannot be changed later.
xattr=sa and acltype=posixacl are what systemd and
journald expect. compression=zstd is close to free and usually
makes things faster. mountpoint=none on the pool root means only
the datasets you name get mounted.
Add encryption at creation time if you want it. Read the key section first, because on a btrfs root the interesting question is where the key lives:
-O encryption=aes-256-gcm -O keyformat=passphrase -O keylocation=prompt \
4. Move /home onto it, carefully
OpenZFS defaults to overlay=on, so it will happily mount a
dataset over a non-empty /home. Your old files are then
invisible but still there, consuming space on the root filesystem, and any new
writes go to the dataset. That is how people conclude their data was "deleted".
Copy first, verify, then swap.
Do this with the user logged out. Switch to a TTY
(Ctrl+Alt+F3), log in as root or use sudo, and
stop the display manager so nothing is writing to /home:
sudo systemctl stop sddm
a. Create the dataset somewhere harmless
sudo zfs create -o mountpoint=/mnt/newhome tank/home
mountpoint -q /mnt/newhome && echo mounted
b. Copy, preserving everything
sudo rsync -aHAX --numeric-ids --info=progress2 /home/ /mnt/newhome/
c. Verify before you trust it
sudo du -sh /home /mnt/newhome
diff <(cd /home && sudo find . | sort) <(cd /mnt/newhome && sudo find . | sort) | head
Sizes will differ slightly. Compression. The file list should not.
d. Swap them
sudo mv /home /home.old
sudo mkdir /home
sudo zfs set mountpoint=/home tank/home
mountpoint -q /home && echo "/home is a real mount"
ls /home
Use mountpoint -q, not findmnt /home.
findmnt reports the nearest enclosing mount, so it says
"mounted" for a plain directory that is not one.
e. Reboot and confirm, then reclaim the space
sudo systemctl reboot
# after logging back in:
mountpoint -q /home && zfs list -o name,used,mountpoint tank/home
# once you are happy, and give that days rather than minutes:
sudo rm -rf /home.old
5. Make it import and mount at boot
Installing the packages does not necessarily enable the services, and a pool that imports but never mounts is worse than one that fails loudly. Check, then enable:
sudo omarchy-zfs-boot-mounts-setup
That opts each pool into zfs-mount-generator, which ships with
zfs-utils and is the piece that does the real work. At boot it reads
/etc/zfs/zfs-list.cache/<pool> and generates a
.mount unit per dataset, plus the
zfs-load-key@<dataset>.service units needed to unlock encrypted
ones, ordered so the key loads first.
The generator only acts on pools that have a file in
/etc/zfs/zfs-list.cache/. On a fresh system that directory does not
exist, so an encrypted pool imports at boot and then just sits there:
keystatus unavailable, nothing mounted, no error anywhere. That is
the whole failure, and it is what the command above fixes. It verifies the
units were generated rather than assuming, and tells you if they were not.
The underlying services still need to be enabled, which the tool checks:
systemctl is-enabled zfs-import-cache zfs-mount zfs-zed zfs-import.target zfs.target
sudo systemctl enable zfs-import-cache.service zfs-mount.service \
zfs-zed.service zfs-import.target zfs.target
sudo zpool set cachefile=/etc/zfs/zpool.cache tank
Doing it by hand instead? A
zfs-load-key@.service template works, but you write the instance
name yourself and systemd reads - as /, so a dataset
with a hyphen in its name silently unlocks the wrong thing. The generator escapes
properly: zfs-load-key@tank-enc-my\x2ddata.service.
/etc/fstab
ZFS mounts them from its own properties. An fstab entry for the same path
races zfs-mount.service and you get intermittent empty mounts. The
exception is a dataset with mountpoint=legacy, which is
only mounted by fstab, which is a choice you make rather than a
default.
The display manager needs /home mounted before it starts. For
that, zfs-mount-generator is the better answer: it turns your dataset
list into real systemd mount units at generator time, so ordinary
After= and Requires= work.
On a ZFS root, omarchy-zfs also ships a
systemd-journal-flush drop-in so journald waits for
/var/log. If you move /var/log to ZFS on a btrfs root,
you want the same ordering, or the system logs nowhere and every later problem
becomes undebuggable.
6. Encryption and where the key lives
Native ZFS encryption is per-dataset and needs no LUKS underneath. The
awkward part on this layout is not the crypto, it is key delivery: something has
to unlock /home before you can log in.
| Approach | How it feels | Notes |
|---|---|---|
keylocation=prompt |
Passphrase at boot, then your login password. | Safe and simple. Two secrets to type. The price of not having a ZFS root. |
| Keyfile on the root filesystem | Unlocks silently. | Only meaningful if the root disk is itself encrypted. On a plain unencrypted btrfs root this protects you against someone stealing the data disk alone, and nothing else. Say that out loud before choosing it. |
pam_zfs_key |
Per-user home dataset unlocked by your login password. | The nicest experience for this layout. We do not package it yet, so you are wiring PAM by hand, including making a password change rekey the dataset. Berend de Boer's Omarchy fork does this. |
# Prompt-at-boot for an existing dataset
sudo zfs change-key -o keylocation=prompt -o keyformat=passphrase tank/home
7. Snapshots, scrubs, replication
Automatic snapshots
omarchy-zfs-autosnap does not run on a btrfs root, so use
sanoid. Already a dependency of the package:
# /etc/sanoid/sanoid.conf
[tank/home]
use_template = production
recursive = yes
[template_production]
frequently = 0
hourly = 24
daily = 14
weekly = 4
monthly = 3
autosnap = yes
autoprune = yes
sudo systemctl enable --now sanoid.timer
systemctl list-timers sanoid.timer
zfs list -t snapshot -r tank
Recover a file without restoring anything. Every dataset has a hidden snapshot directory:
ls /home/.zfs/snapshot/
cp /home/.zfs/snapshot/autosnap_2026-08-20_12:00:00_hourly/pete/notes.md ~/
Scrubs
With omarchy-zfs installed, omarchy-zfs-scrub.timer
scrubs every imported pool monthly. Otherwise schedule it yourself. Check
results. A scrub that finds errors on a single-disk pool can detect but not
repair:
zpool status -v tank
Replication
# Full copy to another pool or machine
sudo zfs snapshot -r tank/home@$(date +%F)
sudo zfs send -R tank/home@$(date +%F) | ssh nas 'zfs receive -u -d backup'
# Later: incremental, only the changes
sudo zfs send -RI tank/home@2026-08-20 tank/home@2026-08-27 | ssh nas 'zfs receive -u -d backup'
For an encrypted dataset, zfs send -w (raw) sends the
still-encrypted blocks: the receiving side stores your data without ever holding
the key, and never needs to re-encrypt. That is the property that makes an
untrusted backup target reasonable, and it is the main thing btrfs has no answer
for. syncoid (part of sanoid) wraps the whole pattern:
syncoid --recursive --sendoptions=w tank/home nas:backup/home
8. Kernel upgrades: the one real cost
ZFS lives outside the kernel tree, so a new kernel can arrive before OpenZFS
supports it. On a machine with ZFS on /home the failure is
specific and confusing: the pool does not import, /home is missing,
and the greeter accepts your password and then throws you straight back to the
login screen. It looks exactly like a wrong password. It is not.
From 1.1.3, omarchy-zfs's pre-transaction guard fires whenever
ZFS is in use. A ZFS root, any imported pool, or simply having a DKMS zfs
package installed. When it blocks an upgrade you get a banner naming the
candidate kernel and the maximum zfs-dkms supports, and pacman
aborts before touching a single package:
ABORT: ZFS kernel compatibility check failed
candidate kernel: 7.2.1.arch1-1
zfs-dkms Linux-Maximum: 7.1
Building zfs against this kernel will fail.
Proceeding would leave the system unbootable on next restart.
Your options, in order of preference:
- Wait. Upgrade everything else:
sudo pacman -Syu --ignore linux,linux-headers. - Move to
zfs-dkms-git, which usually supports new kernels well before a release does. - Run
linux-ltsas your primary kernel. The boring, correct answer for a machine you depend on. - Override, only if you have thought it through and have a
live USB nearby:
OMARCHY_SKIP_ZFS_KERNEL_CHECK=1 sudo -E pacman -Syu.
Whatever you do, before rebooting into a new kernel:
ls /usr/lib/modules/<new-kernel-version>/updates/dkms/zfs.ko*
9. When /home does not show up
Get to a TTY (Ctrl+Alt+F3) and work through this in order:
modprobe zfs || ls /usr/lib/modules/$(uname -r)/updates/dkms/ # module there at all?
zpool status # imported?
sudo zpool import # what is importable?
sudo zpool import -f tank # hostid changed / not cleanly exported
zfs get keystatus tank/home # unavailable -> key not loaded
sudo zfs load-key tank/home
sudo zfs mount -a
mountpoint -q /home && echo ok
sudo systemctl restart sddm
If the module is missing, that is the kernel-skew case above: boot your
previous kernel or linux-lts, rebuild DKMS
(sudo dkms autoinstall), and confirm zfs.ko exists
before rebooting again.
To back the whole thing out, the reverse of the move: copy
/home off the dataset onto the root filesystem, set
mountpoint=none on tank/home, and put the directory
back. Nothing about this layout is one-way.