Arch + LUKS + Btrfs — Encrypted, Snapshot-Ready Single-Boot
The encrypted, snapshot-able install layer I’m actually running on my daily-driver laptop, in the order I executed it. It expands the boring-but-working basic Arch install with:
- LUKS2 full-disk encryption on the root partition,
- Btrfs with proper subvolumes (
@,@home,@snapshots,@var_log,@swap), - zram for fast, compressed, RAM-resident everyday swap,
- a NoCOW Btrfs swap file big enough for hibernation (suspend-to-disk),
- snapper + grub-btrfs for bootable, per-
pacman-transaction snapshots.
It is single-boot and stops on the console — the desktop layer is deliberately a separate choice. Part 4 (at the end) is a fork: pick a pre-bundled desktop (Caelestia, Article 05) or a piece-by-piece custom Hyprland (upcoming) — neither is required for the install itself. Dual-boot and triple-boot setups get their own articles too — those add complexity I want to introduce one variable at a time after this one is rock-solid.
Some sections of this article have variance by machine — the swap-file size, the kernel image you pick, what lspci actually prints. Wherever that’s the case, you’ll see a tabbed block with one example per machine. The tabs don’t change anything else on the page — they just show or hide the example content.
- Generic — the version a reader on unknown hardware should follow.
- Dell Precision (Intel Iris Xe + RTX A2000) — the actual
lspcioutput, exact commands, and post-install env vars from my Dell Precision mobile workstation. This is the machine I’ve fully installed and tested this guide on. - Dell Pro 16 Plus — placeholder for a second machine I’ll install this stack on later. Content lands when I actually run through it.
GPU drivers themselves are part of the desktop layer, not the install — they live in the desktop articles (Article 05 for Caelestia, upcoming for Custom Hyprland), not here.
Why this is its own article (and not just “Article 01, fixed”)
My first attempt at LUKS + Btrfs dropped into emergency mode at boot. I traced the root cause to two typos in the kernel command line: cryptdevice=UUID<…> (missing =) and literal <angle brackets> around the UUID. Those are easy mistakes to make once and never make again, but I also took the chance to step back and pick a different default for this install, because I’m going to be living in it:
- systemd-based initramfs (
sd-encrypt) instead of the olderudev/encrypthook. The kernel parameter format (rd.luks.name=<UUID>=<name>) is documented insystemd-cryptsetup-generator(8), accepts no:separator, and is harder to mistype thancryptdevice=. - No dual-boot. Last time I tried to land LUKS + Btrfs + zram + shared partition + dual-boot Ubuntu in one swing. This time the goal is one OS, fully working, with a desktop on top.
- A real swap file on Btrfs for hibernation, plus zram for normal swap pressure. This replaces last attempt’s “separate encrypted swap partition” — same outcome, fewer partitions.
- Snapper integration from day one, so the very first
pacman -Syualready creates a rollback point.
The result is the install I trust enough to put my dissertation work, my dotfiles, and my GPG keys on.
Part 1 — Bootstrap (same as the basic install)
The first three chapters are identical to the basic install and I won’t repeat them in full. The short version, in order:
- Verify the ISO with
sha256sum -c sha256sums.txtandgpg --verify. - Boot the USB, confirm UEFI mode with
cat /sys/firmware/efi/fw_platform_size(should print64). - Connect to Wi-Fi via
iwctl, thensystemctl enable --now sshdandpasswdso you can SSH in from a real keyboard. - Keymap, timezone, NTP:
loadkeys us,timedatectl set-timezone America/New_York,timedatectl set-ntp true.
Once you have a shell on the target machine with networking and the clock synced, you’re ready to start diverging from the basic install.
lsblk -d -o NAME,SIZE,MODEL,TRANThe NVMe drive will show TRAN=nvme. Note the device — usually /dev/nvme0n1. Every command below uses /dev/nvme0n1 and its partitions as a placeholder. Substitute your actual device if it differs. Wiping the wrong disk is the kind of mistake LUKS can’t save you from.
Chapter 1: Partitioning
For this install I’m using exactly two partitions — the EFI System Partition, and one big Linux partition that I’ll LUKS-encrypt and then carve into Btrfs subvolumes:
| Partition | Size | Filesystem | Encryption | Purpose |
|---|---|---|---|---|
| EFI System | 1 GiB | FAT32 | none | UEFI bootloader, kernel, initramfs |
| Linux | remainder | Btrfs | LUKS2 | /, /home, snapshots, swap file |
That’s it. No separate /home partition (Btrfs subvolumes give you the same isolation without the space-guessing problem). No separate swap partition (the swap file inside Btrfs replaces it). No shared data partition (single boot, so there’s nothing to share with).
cfdisk /dev/nvme0n1Inside cfdisk:
don every existing partition until the table is empty.n→ 1 GiB → type EFI System → that’s/dev/nvme0n1p1.n→ accept the remaining space → type Linux filesystem → that’s/dev/nvme0n1p2.w, then typeyes.
Verify:
lsblkYou should see /dev/nvme0n1p1 at 1G and /dev/nvme0n1p2 filling the rest.
Chapter 2: Encrypt the Linux partition
LUKS2 is the modern default in cryptsetup; there is no good reason to force LUKS1 unless you’re booting from GRUB with an encrypted /boot (we’re not — /boot lives on the unencrypted ESP).
cryptsetup luksFormat --type luks2 /dev/nvme0n1p2It will prompt for YES in capital letters, then ask you to type a passphrase twice. There is no recovery if you forget this. Put it in a password manager before you finish the install. I used Bitwarden.
Now unlock the container and map it to /dev/mapper/cryptroot:
cryptsetup open /dev/nvme0n1p2 cryptrootFrom here on, the live system reads and writes /dev/mapper/cryptroot as a normal block device — LUKS handles the encryption transparently.
The string cryptroot is a contract. It has to match the rd.luks.name=<UUID>=cryptroot value I put on the kernel command line later. If I named the mapper arch here and wrote cryptroot in GRUB later, the kernel would unlock the partition into /dev/mapper/arch and then sit forever waiting for /dev/mapper/cryptroot to appear. That is exactly the failure mode that bricked my first attempt.
Pick one name and use it everywhere. I’ll use cryptroot throughout this guide.
Chapter 3: Create the Btrfs filesystem and its subvolumes
Format the unlocked mapper as Btrfs and format the ESP as FAT32:
mkfs.fat -F32 /dev/nvme0n1p1
mkfs.btrfs -L arch /dev/mapper/cryptrootMount the top of the Btrfs volume just long enough to create subvolumes inside it:
mount /dev/mapper/cryptroot /mnt
btrfs subvolume create /mnt/@
btrfs subvolume create /mnt/@home
btrfs subvolume create /mnt/@snapshots
btrfs subvolume create /mnt/@var_log
btrfs subvolume create /mnt/@swap
umount /mntSubvolume-by-subvolume:
Click to expand
| Subvolume | Mountpoint | Why it’s separate |
|---|---|---|
@ |
/ |
The root filesystem. Snapshotted by snapper. |
@home |
/home |
Separate so a root rollback doesn’t roll back my home directory. |
@snapshots |
/.snapshots |
Where snapper writes snapshots. Must be its own subvolume. |
@var_log |
/var/log |
Logs are append-mostly and would noisy-up every snapshot if kept under @. |
@swap |
/swap |
Dedicated subvolume for the hibernation swap file (must be NoCOW; see below). |
The names @, @home, @snapshots, and @var_log are conventions, not requirements — the kernel doesn’t care. But snapper and Timeshift default to those names. Rename them and you’ll fight your tools forever. I’m using exactly the default names so every guide I read in the future “just works.”
Chapter 4: Mount the subvolumes with the right options
mount -o noatime,compress=zstd:3,subvol=@ /dev/mapper/cryptroot /mnt
mkdir -p /mnt/{boot,home,.snapshots,var/log,swap}
mount -o noatime,compress=zstd:3,subvol=@home /dev/mapper/cryptroot /mnt/home
mount -o noatime,compress=zstd:3,subvol=@snapshots /dev/mapper/cryptroot /mnt/.snapshots
mount -o noatime,compress=zstd:3,subvol=@var_log /dev/mapper/cryptroot /mnt/var/log
mount -o noatime,subvol=@swap /dev/mapper/cryptroot /mnt/swap
mount /dev/nvme0n1p1 /mnt/bootThe mount options earn their keep:
noatime— don’t update the access timestamp on every read. Reduces unnecessary writes on SSDs and is harmless for almost every workload.compress=zstd:3— transparent Zstandard compression at level 3. Strong compression ratio (often 40 %+ on text and source) with negligible CPU cost. Modern Btrfs pickszstd:3if you just saycompress=zstd; spelling out the level is documentation.subvol=@,subvol=@home, … — tells Btrfs which subvolume is mounted at this path.
Notably absent: the ssd option. Modern Btrfs auto-detects NVMe and SSD-class block devices reliably; specifying it is harmless but no longer informative. Also absent: discard=async — I use fstrim.timer (weekly batch TRIM) instead. Doing both is redundant and slightly slower.
The @swap subvolume mounts without compression — a swap file must not be compressed (swap pages need to be readable as-is when the kernel pages them back in).
Recovery: starting the mount step over
If you fumble a mount -o, this resets everything cleanly without rebuilding anything else:
umount -R /mnt
mount | grep /mnt # should print nothing…then re-run the block above.
Chapter 5: Pacstrap, fstab, chroot
Same shape as the basic install, with the additions that LUKS + Btrfs + snapshots + zram require:
reflector --country 'United States' --protocol https --latest 20 --age 12 \
--sort rate --save /etc/pacman.d/mirrorlist
pacstrap -K /mnt \
base linux linux-firmware linux-headers intel-ucode \
cryptsetup \
btrfs-progs \
grub efibootmgr grub-btrfs \
snapper snap-pac \
networkmanager openssh sudo \
neovim git base-devel \
zram-generator \
inotify-tools \
reflectorUse amd-ucode instead of intel-ucode on AMD hardware.
The new packages beyond Article 02:
Click to expand: what each package does
| Package | Why |
|---|---|
cryptsetup |
LUKS userspace + the /usr/lib/initcpio/install/sd-encrypt hook script. Not pulled in by base on current Arch — without it, the next time mkinitcpio runs (e.g., on a kernel update) it would silently produce an initramfs that can’t unlock your disk. Install it explicitly. |
btrfs-progs |
Userspace Btrfs tools (btrfs, mkfs.btrfs, btrfs filesystem mkswapfile). Required on every Btrfs system. |
grub-btrfs |
Generates extra GRUB menu entries for each Btrfs snapshot so you can boot directly into a prior state. |
snapper |
Snapshot manager. Takes per-config snapshots on a timer and on every pacman transaction (via snap-pac). |
snap-pac |
Pacman hook that creates a snapper snapshot before and after every pacman transaction. |
zram-generator |
systemd-friendly zram setup. We’ll point it at half of RAM, zstd-compressed. |
inotify-tools |
Used by grub-btrfs’s grub-btrfsd to watch the snapshots directory and re-generate GRUB on changes. |
reflector |
Mirror-list optimizer. We used it once from the live USB before pacstrap; install it inside the chroot too so reflector.timer exists for the Chapter 11 enable step and the system can re-rank mirrors on its own weekly. |
Both of these print in red but are expected and do not mean the install failed:
sd-vconsole: "/etc/vconsole.conf" not found, will use default values— mkinitcpio built an initramfs before we created/etc/vconsole.conf. The very first boot’s LUKS prompt will use the defaultuskeymap (fine for most people); we set it properly in Chapter 6 and rebuild the initramfs in Chapter 9.fatal library error, lookup selfafterPerforming snapper post snapshots—snap-pac’s pacman hook fired against a snapper that has no configs yet (and perl’s library paths inside the brand-new chroot aren’t fully resolved). The hook would have been a no-op anyway — nothing is snapshotted, nothing is broken. We configure snapper properly in Part 3 (first boot), and the warning disappears from then on.
To confirm pacstrap actually succeeded despite the noise:
ls /mnt/boot/vmlinuz-linux # kernel image is present
ls /mnt/usr/bin/cryptsetup # cryptsetup binary made it in
ls /mnt/etc/ | head # /etc is populated (no fstab yet — that's next)If all three exist, you’re good.
Generate fstab and enter the new system:
genfstab -U /mnt >> /mnt/etc/fstab
arch-chroot /mntcat /etc/fstabEach line should reference UUID=… (not /dev/nvme0n1pN) and the Btrfs entries should each carry subvol=@… in their options. If you see anything mounted from /dev/mapper/cryptroot without a subvol= entry, edit that out — you’d be mounting the top-level Btrfs volume on top of itself.
Part 2 — Inside the chroot
Chapter 6: Locale, time, hostname, user
Identical to the basic install — I won’t re-explain each step. The compressed version:
# Editor + EDITOR var
ln -sf /usr/bin/nvim /usr/local/bin/vi
echo 'export EDITOR=nvim' >> /etc/profile
# Timezone + hardware clock — **substitute your zone**, not mine
ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime
hwclock --systohc
# Locale
sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen
locale-gen
echo "LANG=en_US.UTF-8" > /etc/locale.conf
echo "KEYMAP=us" > /etc/vconsole.conf
# Hostname
echo "arch" > /etc/hostname
# /etc/hosts (so the hostname resolves locally)
cat > /etc/hosts <<'EOF'
127.0.0.1 localhost
::1 localhost
127.0.1.1 arch.localdomain arch
EOF
# Root password
passwd
# User account
useradd -m -g users -G wheel,video,audio,input evanns
passwd evanns
# sudo for wheel
sed -i 's/^# %wheel ALL=(ALL:ALL) ALL/%wheel ALL=(ALL:ALL) ALL/' /etc/sudoersReplace evanns with whatever username you want. I’m using arch as the bare hostname (so the shell prompt reads evanns@arch) — if you’d rather have a more descriptive hostname like arch-thinkpad or lab-laptop, change both the /etc/hostname line and the trailing arch.localdomain arch token in /etc/hosts. The extra groups (video, audio, input) preempt small annoyances later with Hyprland (video for GPU access, input for libinput devices, audio for the legacy ALSA path some apps still want).
The ln -sf /usr/share/zoneinfo/America/New_York /etc/localtime line above sets the system timezone to my zone (US Eastern). Change it to wherever you actually are, or you’ll be doing time-zone math in your head for the lifetime of this install. Find your zone first:
timedatectl list-timezones | grep -E 'America|Europe|Asia|Australia' | less
# e.g. America/Chicago, America/Los_Angeles, Europe/Madrid, Asia/TokyoThen swap the path in the ln -sf command. Examples for common ones:
ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime # US Central
ln -sf /usr/share/zoneinfo/America/Los_Angeles /etc/localtime # US Pacific
ln -sf /usr/share/zoneinfo/Europe/Madrid /etc/localtime # CET
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime # JSTIf you realize you set the wrong zone after first boot, no need to redo anything in chroot — sudo timedatectl set-timezone <Region/City> from the running system fixes it instantly. There’s a verification step in Part 3 that walks you through this.
/etc/hosts is, and what arch.localdomain means
/etc/hosts is the system’s local name-resolution file. The NSS resolver checks it before going to DNS or mDNS (the order is set in /etc/nsswitch.conf), so anything listed here resolves instantly without a network round-trip.
Line-by-line, here’s what we just wrote:
| Line | What it does |
|---|---|
127.0.0.1 localhost |
Maps the loopback name localhost to the IPv4 loopback address. Ships in Arch’s default /etc/hosts. |
::1 localhost |
Same idea for IPv6. The ::1 address is the IPv6 loopback. |
127.0.1.1 arch.localdomain arch |
Maps this machine’s own hostname (arch) and its fully-qualified name (arch.localdomain) to a different loopback address — 127.0.1.1, note the third octet. |
Two things are worth knowing about that third line:
Why a separate
127.0.1.1instead of127.0.0.1? It’s a Debian-ism the Arch Wiki adopted: by giving your hostname its own loopback address, nothing on a real network can ever depend on you accidentally pointing your hostname at the generic loopback.127.0.0.0/8is a /8 subnet (16 million addresses) entirely reserved for loopback, so there’s room to spare and127.0.1.1is also purely local — it never leaves your machine. Functionally either would work; conventionally,127.0.1.1is what you use.What does
.localdomainmean? It’s a placeholder domain suffix for machines that aren’t on a real DNS domain — a stand-in fully-qualified name when there’s no actual one. Calls likehostname -f(get the FQDN),gethostname(), andgetfqdn()from various languages all walk/etc/hostslooking for an entry that names this host. Witharch.localdomain archon the third line, they all returnarch.localdomaininstead of failing or returning an empty string. If you ever actually join a domain (mycompany.internal, say), you’d change this toarch.mycompany.internal arch.
Why it matters in practice: without this line, sudo adds a 1–3 second pause on each invocation while it tries (and fails) to reverse-resolve your hostname. Mail clients, print spoolers, and a handful of other tools have the same pattern. Adding the line is essentially free and removes the delay.
Don’t edit /etc/hostname by hand on a running system — use hostnamectl, which updates the file, the kernel’s runtime hostname, and the systemd-hostnamed D-Bus property in one shot:
sudo hostnamectl set-hostname archThen update the third line of /etc/hosts to match. If /etc/hosts is still at the default (only the two localhost lines), append the missing entry without touching what’s there:
echo "127.0.1.1 arch.localdomain arch" | sudo tee -a /etc/hostsVerify it took:
cat /etc/hosts # should now have three lines
getent hosts arch # should print "127.0.1.1 arch.localdomain arch"Then open a new shell (or log out / log back in) — your current shell cached $HOSTNAME at startup and won’t update in place. Substitute arch with whatever hostname you actually want in both commands.
Chapter 7: The hibernation swap file
This is the new ground vs. both Article 02 (no swap file at all) and Article 01 (separate encrypted swap partition). Since the whole root volume is LUKS-encrypted, a swap file inside it is automatically encrypted too — no extra cryptsetup dance.
Why a swap file and zram?
Different jobs, complementary defaults:
- zram is fast, compressed swap in RAM. Used first (higher priority). Almost all everyday memory pressure goes here, and you never pay an SSD write for it.
- Swap file is durable swap on disk. The only kind that survives a power cycle. Hibernation writes the contents of RAM into it; on resume, the kernel reads it back.
zram alone cannot host hibernation — it lives in RAM and disappears at power-off. So we need both: zram for everyday performance, swap file for hibernation.
Create the swap file the Btrfs-correct way
The naïve fallocate + mkswap recipe corrupts Btrfs swap files. Modern btrfs-progs ships a single tool that does it right:
btrfs filesystem mkswapfile --size 20g --uuid clear /swap/swapfileWhat that one command does for you:
- Creates a file at
/swap/swapfile. - Sets the NoCOW attribute (
chattr +C) — required because copy-on-write swap files break in interesting and silent ways. - Disables compression on the file.
- Preallocates extents (so the kernel can compute a stable offset for hibernation).
- Runs
mkswapagainst it. - With
--uuid clear, leaves the swap UUID blank, which avoids a stale-UUID warning on firstswaponafter a fresh install.
swapon from inside the chroot
It’s tempting to immediately run swapon /swap/swapfile to “verify” it works. Don’t. That would activate the swap file on the live USB’s kernel (the chroot doesn’t have its own), which then prevents you from unmounting /mnt cleanly at reboot time, and can leave the swap signature in an “in use” state when you try to power-cycle. The first boot of the installed system will activate the swap file via /etc/fstab automatically — that’s the right time.
Pick a size that fits your RAM plus a small margin. For 16 GB RAM I’d use 20 GB. For 32 GB RAM I’d use 36 GB. Hibernation needs to be able to write the full contents of RAM to disk; running out of swap mid-hibernate corrupts the resume image.
Persist the swap file in /etc/fstab (append; genfstab won’t have caught it):
echo '/swap/swapfile none swap defaults 0 0' >> /etc/fstabFind the resume offset
Hibernation resume needs to know the byte offset of the swap file’s first physical block inside the underlying device — there’s no filesystem layer during resume, just the raw block device.
btrfs inspect-internal map-swapfile -r /swap/swapfileThe -r flag prints the offset in the resume-friendly units the kernel expects (pages, not bytes). Save the number it prints — we’ll feed it to GRUB in the next chapter.
I’ll refer to it as <RESUME_OFFSET> below.
Chapter 8: zram for everyday swap
Create /etc/systemd/zram-generator.conf:
[zram0]
zram-size = ram / 2
compression-algorithm = zstd
swap-priority = 100
fs-type = swapWhat each key does:
| Key | What it sets |
|---|---|
zram-size = ram / 2 |
The zram device gets half of physical RAM. Compressed inside the kernel; typical 3–4× compression ratio means you effectively double your usable memory before the disk swap kicks in. |
compression-algorithm |
zstd is fast and compresses well — the right default in 2026. |
swap-priority = 100 |
High priority means the kernel prefers zram over the swap file. The swap file’s priority defaults to ~–2 in /etc/fstab, so zram is used first; swap file only as overflow and for hibernation. |
fs-type = swap |
Treat the zram device as swap, not a regular filesystem. |
Nothing else needed — the systemd-zram-setup@zram0.service unit is generated automatically at boot from this config.
Chapter 9: mkinitcpio — the systemd way
This is the chapter that has to be right or nothing boots. We’re using the systemd initramfs (formerly called the “systemd hooks” or “sd-encrypt path”), which differs from the udev/encrypt flow my previous attempt used.
Open /etc/mkinitcpio.conf and set exactly these two lines:
MODULES=(btrfs)
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck)Hook-by-hook (only the changed-vs-Article-01 ones):
| Hook | What it does |
|---|---|
systemd |
Replaces udev. Boots a small systemd inside the initramfs and uses unit files to coordinate early boot. |
sd-vconsole |
Loads the console keymap inside the initramfs — needed so your LUKS prompt accepts your password as typed. |
sd-encrypt |
systemd’s equivalent of the encrypt hook. Honors rd.luks.name=… on the kernel cmdline. |
Why MODULES=(btrfs) even though filesystems covers it? autodetect should pull in btrfs.ko automatically since the root filesystem is Btrfs, but being explicit costs nothing and guards against a one-off edge case where autodetect strips it.
Now regenerate the initramfs for all installed kernels:
mkinitcpio -PA successful run ends with Image generation successful for each preset (linux, plus linux-lts if you installed it). Errors here are loud; address them now, not after reboot.
Chapter 10: GRUB — the cmdline that actually boots
Get the UUID of the encrypted partition itself (/dev/nvme0n1p2, not the mapper):
blkid -s UUID -o value /dev/nvme0n1p2That returns a string like 0e6caf44-4ba9-4279-8f29-ad2344ea4387. Copy it.
Open /etc/default/grub and find:
GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 quiet"Replace it with this single line (no real angle brackets, no extra quoting — just the bare UUID where it says <UUID>, and the integer where it says <RESUME_OFFSET>):
GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 quiet zswap.enabled=0 rd.luks.name=<UUID>=cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ resume=/dev/mapper/cryptroot resume_offset=<RESUME_OFFSET>"Replace <UUID> with the bare UUID and <RESUME_OFFSET> with the integer from btrfs inspect-internal map-swapfile -r. Do not include the angle brackets in the actual file. That is exactly the second mistake I made in my first attempt and it’s the kind of typo that produces a polite kernel message followed by ninety seconds of timeout and an emergency shell — recovery in the 🚨 emergency section at the bottom of this article.
Kernel-parameter-by-kernel-parameter:
Click to expand
| Parameter | Purpose |
|---|---|
loglevel=3 |
Quieter kernel boot logs (warnings and above). |
quiet |
Suppress most non-critical kernel boot output. |
zswap.enabled=0 |
Disable zswap; we’re using zram for compressed swap. |
rd.luks.name=<UUID>=cryptroot |
Unlock LUKS partition <UUID> and expose it at /dev/mapper/cryptroot. |
root=/dev/mapper/cryptroot |
The unlocked mapper is the root device. |
rootflags=subvol=@ |
Mount the @ Btrfs subvolume as /. |
resume=/dev/mapper/cryptroot |
Resume hibernation from this device (the LUKS-unlocked Btrfs). |
resume_offset=<RESUME_OFFSET> |
Byte offset (in pages) of the swap file’s first extent inside that device. |
Install the bootloader and generate the GRUB config:
grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB
grub-mkconfig -o /boot/grub/grub.cfgThe grub-mkconfig output should end with done. If you see error: failed to get canonical path of …, your /etc/fstab has a typo — fix it before rebooting.
GRUB_ENABLE_CRYPTODISK=y
GRUB_ENABLE_CRYPTODISK=y matters when GRUB itself has to read encrypted blocks — i.e., when /boot (and therefore grub.cfg, kernels, and initramfs images) is inside a LUKS container. In this install, /boot is the unencrypted FAT32 ESP, so GRUB never has to decrypt anything; the kernel and initramfs are read in clear, and decryption happens inside the initramfs once it’s running. Leaving GRUB_ENABLE_CRYPTODISK unset is the right default.
Chapter 11: Enable services and reboot
systemctl enable NetworkManager sshd fstrim.timer reflector.timer \
snapper-timeline.timer snapper-cleanup.timer \
grub-btrfsd.serviceWhat each timer/service is for, in addition to what Article 02 enabled:
| Unit | Why |
|---|---|
snapper-timeline.timer |
Hourly snapshots (configurable). |
snapper-cleanup.timer |
Garbage-collects old snapshots according to retention policy. |
grub-btrfsd.service |
Watches /.snapshots/ and regenerates grub.cfg whenever snapper makes or deletes a snapshot — keeps the boot menu up to date with restorable points. |
Final sanity checks before reboot:
grep '^MODULES=' /etc/mkinitcpio.conf
grep '^HOOKS=' /etc/mkinitcpio.conf
grep '^GRUB_CMDLINE_LINUX_DEFAULT=' /etc/default/grub
cat /etc/fstab | grep -E 'subvol=|swap'
swapon --showHere is the actual output from my own install, end-to-end — yours should have the same shape, with your own UUIDs and resume offset:
MODULES=(btrfs)
HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck)
GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 quiet zswap.enabled=0 rd.luks.name=d2c12564-6eeb-4ae9-a862-a016a1da7132=cryptroot root=/dev/mapper/cryptroot rootflags=subvol=@ resume=/dev/mapper/cryptroot resume_offset=1320192"
UUID=a0c30a40-14f5-444d-a23a-abb236c8ed01 / btrfs rw,noatime,compress=zstd:3,ssd,space_cache=v2,subvol=/@ 0 0
UUID=a0c30a40-14f5-444d-a23a-abb236c8ed01 /home btrfs rw,noatime,compress=zstd:3,ssd,space_cache=v2,subvol=/@home 0 0
UUID=a0c30a40-14f5-444d-a23a-abb236c8ed01 /.snapshots btrfs rw,noatime,compress=zstd:3,ssd,space_cache=v2,subvol=/@snapshots 0 0
UUID=a0c30a40-14f5-444d-a23a-abb236c8ed01 /var/log btrfs rw,noatime,compress=zstd:3,ssd,space_cache=v2,subvol=/@var_log 0 0
UUID=a0c30a40-14f5-444d-a23a-abb236c8ed01 /swap btrfs rw,noatime,compress=zstd:3,ssd,space_cache=v2,subvol=/@swap 0 0
/swap/swapfile none swap defaults 0 0
NAME TYPE SIZE USED PRIO
/swap/swapfile file 20G 0B -1
Look closely — the GRUB line and the fstab lines reference different UUIDs:
rd.luks.name=d2c12564-…=cryptrootis the LUKS partition UUID. It identifies the raw encrypted block device (/dev/nvme0n1p2). GRUB hands this string to the kernel; sd-encrypt uses it to find the partition to unlock.UUID=a0c30a40-…in every fstab line is the Btrfs filesystem UUID — the filesystem that lives inside the unlocked LUKS container, on/dev/mapper/cryptroot. genfstab uses this so the mount entries survive even if the underlying mapper changes name.
Two layers, two identifiers. If you ever confuse them — pasting the Btrfs UUID into rd.luks.name=, for example — the kernel will look for a LUKS partition with the Btrfs UUID, fail to find it, and you’ll get the “Timed out waiting for device” emergency-mode failure mode from Article 01 (recovery: 🚨 emergency section at the bottom of this article). Verify with blkid /dev/nvme0n1p2 (should print TYPE="crypto_LUKS") — that’s the partition whose UUID belongs on the GRUB cmdline.
The fact that /swap/swapfile appears active in this output is because I ran swapon /swap/swapfile inside the chroot to test it before realizing that’s exactly what the Don’t swapon from inside the chroot callout warns against. The reboot block below has swapoff -a before umount -R /mnt specifically so this case still cleans up correctly — but the cleanest path is to skip the test-swapon entirely and trust that /etc/fstab will pick it up on first boot.
Then leave the chroot and reboot, in this exact order:
exit # leave the chroot first
swapoff -a # turn off any active swap (no-op if you didn't swapon)
umount -R /mnt # then unmount the new system
cryptsetup close cryptroot # then lock the LUKS container back up
rebootThe order matters: you cannot umount a Btrfs subvolume that still has an active swap file on it, and you cannot cryptsetup close a mapper that still has mounted filesystems on top of it. Each command depends on the previous one having completed cleanly.
Pull the USB as the laptop starts. The expected sequence:
- Firmware → GRUB menu (Arch is the only entry; press Enter).
- Kernel + initramfs load from the ESP.
- systemd-in-initramfs prompts:
Please enter passphrase for disk arch (cryptroot):. - You type the LUKS passphrase, hit Enter.
- The mapper appears, the
@subvolume mounts as/, control hands off to the real systemd. - You land at a
login:prompt.
If you instead drop into emergency mode, jump to the 🚨 emergency section at the bottom of this article before continuing — that’s the documented recovery path for the “Timed out waiting for device” failure mode.
Part 3 — First boot: snapper, snapshots, sanity checks
Log in as your user. First thing:
sudo systemctl status # System should be "running"
findmnt / # Should show subvol=/@
findmnt /home # Should show subvol=/@home
swapon --show # Should show /dev/zram0 (pri 100) AND /swap/swapfileVerify your hostname (and fix it if it’s off)
Your shell prompt should already be reading <your-user>@arch (e.g. evanns@arch). If you instead see something like evanns@evanns-arch — because you copy-pasted the chroot block from an earlier draft of this article, or just picked a different name in Chapter 6 — fix it now. The hostname is what shows up in your prompt, in mDNS (arch.local for things like AirDrop-style discovery), in NetworkManager’s DHCP requests, and in every journalctl entry from now on; getting it right early saves grep noise forever.
Check what’s currently set:
hostnamectl # full status (Static hostname, Icon name, Chassis, …)
cat /etc/hosts # should have 3 lines — see belowIf hostnamectl reports anything other than what you want, change it with one command (no editing /etc/hostname by hand on a live system — hostnamectl updates the file, the kernel’s runtime hostname, and the systemd-hostnamed D-Bus property in one shot):
sudo hostnamectl set-hostname arch # substitute your preferred hostnameThen make sure /etc/hosts has the matching third line. The default Arch /etc/hosts ships with only the two localhost entries, so most likely you need to append the third one. (See the What /etc/hosts is, and what arch.localdomain means callout in Chapter 6 for the why behind this line.)
cat /etc/hosts # do you see "127.0.1.1 ... arch ..." already?
# If not, append it:
echo "127.0.1.1 arch.localdomain arch" | sudo tee -a /etc/hosts
getent hosts arch # should print: 127.0.1.1 arch.localdomain archSubstitute arch everywhere with whatever hostname you actually picked.
The kernel hostname changed instantly, but two things still see the old value:
- Your current shell cached
$HOSTNAMEat startup. Your prompt won’t update until you open a new shell — either log out and back in, orCtrl-Alt-F2to another TTY. - Long-running services that read the hostname once at boot (NetworkManager publishing your name over mDNS, for example) keep the old name until restarted.
You can fix both without a reboot:
exec bash # replace the current shell — prompt now shows the new hostname
sudo systemctl restart NetworkManager…but the cleanest, most thorough way is to reboot once after this point, because we’re going to make more changes (snapper, services, packages) and a reboot gives every daemon a clean slate that reflects the new hostname:
sudo rebootAfter the reboot you’ll need to re-unlock LUKS, log back in, and your prompt should read <user>@arch cleanly.
Reconnect to Wi-Fi (do this before anything else needs the network)
NetworkManager is enabled and running, but it has no saved networks — your Wi-Fi credentials lived in the live USB’s iwd/iwctl session, not in the installed system. The very first thing to do after the sanity checks is re-establish a connection so pacman, timesyncd, snapper, and everything downstream actually works.
If you have an Ethernet cable, plug it in — NetworkManager runs DHCP automatically and you’re online in a couple seconds. Skip to the next subsection.
For Wi-Fi, three nmcli commands:
# 1. Make sure the wifi radio is on (it usually is, but cheap to confirm)
nmcli radio wifi on
# 2. See what networks are in range
nmcli device wifi list
# 3. Connect — quote the SSID if it has spaces; quote the password if it has $ or !
nmcli device wifi connect "Your SSID Here" password "your-passphrase"You should see a line like Device 'wlan0' successfully activated with '<uuid>'. Verify you actually have an IP and can reach the world:
ip -brief address # wlan0 should have an inet address now
ping -c 3 ping.archlinux.org # should see <1% packet lossnmcli patterns worth knowing
Click to expand: what each command does
| Goal | Command |
|---|---|
| List all saved connections | nmcli connection show |
| Reconnect to a saved network manually | nmcli connection up "Your SSID Here" |
| Forget a saved network | nmcli connection delete "Your SSID Here" |
| Toggle wifi off / on | nmcli radio wifi off / nmcli radio wifi on |
| Get connection status in a glance | nmcli device status |
| See current Wi-Fi signal / bitrate | nmcli -f IN-USE,SSID,SIGNAL,BARS device wifi list |
| Connect to a hidden SSID | nmcli device wifi connect "Hidden SSID" password "…" hidden yes |
The saved-connection file lives at /etc/NetworkManager/system-connections/<SSID>.nmconnection, mode 600, root-owned, with the passphrase in plaintext — back it up or treat it as a secret accordingly.
Re-enable NTP and verify your timezone
Two related-but-separate clock checks at first boot.
NTP: the timedatectl set-ntp true you ran from the live ISO only affected the live environment. The installed system has its own systemd-timesyncd, and on first boot it’s idle until you turn it on:
sudo timedatectl set-ntp trueTimezone: timedatectl status shows what’s currently set. If it says something other than the zone you wanted (you might be staring at the article’s default America/New_York because you copy-pasted the chroot block without substituting), fix it now:
timedatectl status # what's currently set?
timedatectl list-timezones | grep -i <your city> # find the right Region/City
sudo timedatectl set-timezone America/Chicago # substitute yours
timedatectl status # confirm it tookThe result you want from timedatectl status should look like:
Local time: Sun 2026-05-17 12:34:56 CDT
Universal time: Sun 2026-05-17 17:34:56 UTC
RTC time: Sun 2026-05-17 17:34:56
Time zone: America/Chicago (CDT, -0500)
System clock synchronized: yes
NTP service: active
RTC in local TZ: no
Three things to check at a glance:
- Time zone matches where you actually are.
- System clock synchronized: yes (means NTP is working).
- RTC in local TZ: no (means the hardware clock is in UTC, which is the only correct setting —
yeshere causes wall-clock weirdness around DST transitions).
Without NTP on, the clock drifts to whatever the hardware RTC says, and pacman operations start failing with mysterious GPG / TLS errors as soon as the skew gets large enough. Without the right zone, every timestamp you ever see is off by 1–24 hours. Both fixes are cheap and permanent — do them now.
Configure snapper for / properly
snapper -c root create-config / would create its own .snapshots subvolume, which collides with the @snapshots we already mounted. The Arch-recommended dance:
sudo umount /.snapshots
sudo rm -rf /.snapshots
sudo snapper -c root create-config /
sudo btrfs subvolume delete /.snapshots # delete the one snapper just created
sudo mkdir /.snapshots
sudo chmod 750 /.snapshots
sudo mount -a # remount @snapshots from fstabTighten retention to something humane (the defaults keep too many):
sudo snapper -c root set-config \
TIMELINE_LIMIT_HOURLY=5 \
TIMELINE_LIMIT_DAILY=7 \
TIMELINE_LIMIT_WEEKLY=2 \
TIMELINE_LIMIT_MONTHLY=1 \
TIMELINE_LIMIT_YEARLY=0Should you also snapshot /home?
A live question worth thinking through rather than enabling reflexively, because the answer changes the disk-usage profile of your install meaningfully.
Why it’s tempting: rolling back an accidental rm, an editor that ate a file, or a misbehaving app that scrambled your dotfiles.
Why it’s not free: snapshots are copy-on-write, so they cost nothing until files diverge. The catch is that /home on a working machine is full of “write-once-delete-soon” data — cargo target/, node_modules/, ~/.cache/, ROS build/ and install/, training checkpoints, downloaded tarballs. Every snapshot pins those deleted extents until the snapshot ages out, so a build-heavy /home (research code, ML, robotics workspaces) can balloon snapper’s footprint by many gigabytes in days.
What snapper for /home doesn’t do: it doesn’t protect against disk failure, theft, fire, or sudo rm -rf / across the whole filesystem. Those need a backup — restic or borg to a NAS, an external drive, or a cloud bucket. Snapshots and backups solve different problems; for /home data you actually care about, the backup is the load-bearing tool.
Click to expand
| Your workflow looks like… | Recommendation |
|---|---|
| Code, papers, configs, mostly git-tracked | Skip — git already gives you rollback for the things you’d want to roll back |
| Heavy builds (cargo / npm / Python / ROS / ML) | Skip unless you mark build dirs NoCOW first (see below) |
| Large datasets, video, ROS bags, model checkpoints | Skip — snapshot growth scales with edits to these |
| Hand-edited dotfiles you don’t track in git | Enable with tight retention (next block) |
| Mostly small documents / email / browser profile | Enable with tight retention |
The default snapper retention is too generous for /home. This config keeps only ~6 rolling snapshots, which is enough to recover from “I deleted that file twenty minutes ago” without storing a month of build artifacts:
sudo snapper -c home create-config /home
sudo snapper -c home set-config \
TIMELINE_LIMIT_HOURLY=2 \
TIMELINE_LIMIT_DAILY=3 \
TIMELINE_LIMIT_WEEKLY=1 \
TIMELINE_LIMIT_MONTHLY=0 \
TIMELINE_LIMIT_YEARLY=0snapper-timeline.timer and snapper-cleanup.timer are already enabled (from Chapter 11), so they automatically apply this config too.
Even on the live filesystem (no snapshots involved), Btrfs’s copy-on-write semantics produce fragmentation on heavily-rewritten files — exactly what cargo target/, ~/.cache/, and Python venvs are. Setting NoCOW on those directories before you populate them tells Btrfs to skip COW for the files inside, which both prevents that fragmentation and means snapshots (if you turn them on later) never pin those build artifacts.
mkdir -p ~/.cache ~/code/builds ~/.cargo
chattr +C ~/.cache ~/code/builds ~/.cargo # new files inside inherit NoCOW
# ROS workspace pattern — mark build/install/log before colcon ever creates them
mkdir -p ~/ros2_ws/{build,install,log}
chattr +C ~/ros2_ws/{build,install,log}chattr +C only affects new files — existing files in the directory keep their old (COW) attributes. So set this on empty directories before they fill up, or recreate the directory contents to inherit it.
/home, why not just NoCOW the whole thing?”
Reasonable question — and the answer is a trade-off. NoCOW disables four things Btrfs gives you by default, three of which you probably want for most of /home:
| What CoW gives you | What NoCOW takes away |
|---|---|
| Per-extent data checksums — Btrfs verifies every read; silently catches bitrot on disk and refuses to return corrupted data. | NoCOW files have no per-extent checksums. A flipped bit in a paper draft or a git pack file returns wrong bytes instead of failing loudly. |
Transparent compression (your compress=zstd:3 mount option) — text-heavy data (source code, LaTeX, JSON, logs, markdown) typically compresses 50–70 %. |
NoCOW disables compression for that file. A typical /home of code + papers loses several GB of effective free space if NoCOW’d whole. |
Reflinks — cp --reflink=auto finishes in milliseconds and uses zero extra disk because both names point at the same extents. Git, snapshot-y backup tools, and cp itself rely on this. |
NoCOW files can’t be reflinked; cp --reflink=auto silently falls back to a normal byte-for-byte copy. |
Snapshot semantics — if you ever change your mind and snapper -c home create-config /home, snapshots correctly preserve file state. |
NoCOW files appear in snapshots but modifications are visible across all of them — snapshots silently don’t capture the “before” state. Breaks rollback for those files. |
The CoW cost (the fragmentation we just talked about) only shows up on files with a write-modify-write-modify pattern — build artifacts, browser cache, ML checkpoints, VM disk images, hot database files. For the rest of /home (code repos, papers, photos, configs, downloads, dotfiles), files are written once and read many times — CoW’s overhead on those is essentially zero, and you get checksums + compression + reflinks for free.
The rule that actually works: NoCOW the specific subtrees with bad write patterns, leave everything else CoW.
Click to expand
| Should be NoCOW | Should stay CoW |
|---|---|
~/.cache (browser, thumbnail, font, IDE caches) |
Code repositories (~/code/<project>) |
~/.cargo/target/, ~/ros2_ws/build/install/log/, ~/.gradle/, node_modules/ |
Papers, manuscripts, LaTeX trees |
| ML training output (checkpoints overwritten each epoch) | Photos, videos, music, downloads |
KVM/QEMU .qcow2 disk images (CoW-inside-CoW is pathological) |
Dotfiles, ~/.config, ~/.local/share |
| Hot SQLite databases (some IDE indexes, some mail clients) | Everything else in /home not explicitly listed left |
The targeted chattr +C on just those subtrees is the minimum NoCOW footprint that solves fragmentation without giving up checksums, compression, or reflinks on the parts of /home that actually benefit from them.
Verify snap-pac is wired up:
sudo pacman -Syu # any pacman transaction now creates a pre + post snapshot
sudo snapper -c root listWhat you should see depends on whether pacman -Syu actually did anything:
- If packages were upgraded: a pre + post snapshot pair appears, bracketing the update — descriptions like
pacman -Syuand cleanupnumber. From here on, every upgradingpacmaninvocation gives you a rollback point. - If
pacman -Syuprintedthere is nothing to do(likely on a fresh install — pacstrap already pulled the latest from a current mirror): no pre/post snapshots are created, because no transaction happened. snap-pac hooks transactions, not bare-Syusyncs. You’ll only see snapshot0 │ single │ current, which is snapper’s reference placeholder, not real data.
To prove snap-pac is wired up without waiting for a real upgrade, either force a manual snapshot or install one small package:
# Option A — manual snapshot
sudo snapper -c root create --description "manual baseline"
# Option B — install something to trigger the snap-pac hook
sudo pacman -S htop # creates pre + post snapshots
sudo snapper -c root list # snapshots 1, 2, 3 should now appear
# sudo pacman -R htop # optional cleanup — this also triggers a pre + post pairAfter Option B, the snap-pac hook output during the install confirms snapshots were taken (look for ==> root: 1 and ==> root: 2 lines), and snapper -c root list shows them:
:: Running pre-transaction hooks...
(1/1) Performing snapper pre snapshots for the following configurations...
==> root: 1
:: Processing package changes...
(1/1) installing htop [###############################] 100%
:: Running post-transaction hooks...
(1/2) Arming ConditionNeedsUpdate...
(2/2) Performing snapper post snapshots for the following configurations...
==> root: 2
[evanns@arch ~]$ sudo snapper -c root list
# │ Type │ Pre # │ Date │ User │ Cleanup │ Description │ Userdata
──┼────────┼───────┼─────────────────────────────────┼──────┼─────────┼────────────────┼─────────
0 │ single │ │ │ root │ │ current │
1 │ pre │ │ Sun 17 May 2026 09:20:30 PM CDT │ root │ number │ pacman -S htop │
2 │ post │ 1 │ Sun 17 May 2026 09:20:30 PM CDT │ root │ number │ htop │
What this table is and what each column shows
snapper list prints every snapshot for the named config (-c root here means the / snapper config we set up earlier). Each row is one snapshot. Column-by-column:
Click to expand
| Column | What it means |
|---|---|
# |
The snapshot’s integer ID, assigned in order of creation. 0 is special — see below. |
Type |
single (a standalone snapshot), pre (taken before a transaction), or post (taken after a transaction). pre/post always come in pairs created by the same pacman invocation. |
Pre # |
Only filled in on post rows. Points back at the matching pre snapshot. Lets snapper treat the pair as a unit when you query or roll back. |
Date |
Local timestamp the snapshot was taken. |
User |
Which user ran the command that created the snapshot (almost always root because snap-pac runs as a pacman hook). |
Cleanup |
The retention policy that decides when this snapshot gets garbage-collected. number = keep the last N pre/post pairs (snap-pac’s default is 10). timeline = keep by age per the TIMELINE_LIMIT_* config. Empty = keep forever (e.g., snapshot 0 and any manual baseline snapshots you create yourself). |
Description |
A human-readable label. For pre snapshots, snap-pac writes the pacman command (pacman -S htop). For post, snap-pac writes the package list that changed (htop). For manual snapshots, whatever you passed to --description. |
Userdata |
Optional key=value metadata you can attach. Empty by default. |
A few specifics about what we’re seeing above:
- Snapshot
0 │ single │ currentis snapper’s reference placeholder, created when you ransnapper -c root create-config /. It’s not a real point-in-time copy — it’s an abstract pointer to “the live state of/right now.” You can’t roll back to it (you’re already there), but tools use it as the “before” half of various diffs. - Snapshot
1 │ prewas taken by snap-pac the instant before pacman started touching the filesystem. Description is the exact pacman command line. Cleanup isnumber, so this snapshot stays around until ten newer pre/post pairs exist. - Snapshot
2 │ postwas taken the instant after pacman finished.Pre # = 1ties it back to its pre. Description is the package(s) that were affected. Same timestamp as snapshot 1 because the install took less than a second.
What you can do with this pair:
# See exactly what files changed during the htop install
sudo snapper -c root status 1..2
# Show a unified diff of the changed files
sudo snapper -c root diff 1..2
# Roll back the entire filesystem to *before* htop was installed
sudo snapper -c root undochange 1..2 # safer: just revert the files
# or, full rollback (changes default subvolume; needs reboot):
sudo snapper -c root rollback 1undochange reverts the file changes only — safe and reversible. rollback is heavier: it makes snapshot 1’s tree the new default subvolume, and the next boot uses that subvolume as /. Use rollback to recover from a system that won’t boot after a bad update; use undochange for “I wish I hadn’t installed that one package.”
The snapper-timeline.timer enabled in Chapter 11 fires OnCalendar=hourly, so within an hour of first boot, snapshots will also start appearing automatically with cleanup timeline. You’ll soon see a steadily-growing list with descriptions like timeline, garbage-collected per the TIMELINE_LIMIT_HOURLY=5 / DAILY=7 / WEEKLY=2 / MONTHLY=1 retention you set.
Test hibernation before you trust it
sudo systemctl hibernateThe screen should go dark, the fans stop, the power LED dim. Press the power button to wake it. The expected sequence on resume:
- Firmware → GRUB → kernel + initramfs (same as a cold boot).
sd-encryptprompts for your LUKS passphrase — see the security-model callout below for why this is the only prompt you should see.- After unlock,
systemd-hibernate-resume.servicereads the hibernation image from/swap/swapfile(decrypted on the fly by LUKS as it reads), restores RAM, and userspace picks up where it left off.
If the system instead boots fresh (clean login prompt, no windows restored), your resume= parameters didn’t take — re-check the GRUB cmdline and re-run mkinitcpio -P && grub-mkconfig -o /boot/grub/grub.cfg.
What dmesg should show after a successful hibernate + resume
sudo dmesg | grep -i hibernateTwo informational lines, no errors:
[ 25.728339] systemd[1]: Clear Stale Hibernate Storage Info skipped, unmet condition check ConditionPathExists=/sys/firmware/efi/efivars/HibernateLocation-8cf2644b-4b0b-428f-9387-6d876050dc67
[ 948.817235] efivarfs: removing variable HibernateLocation-8cf2644b-4b0b-428f-9387-6d876050dc67
Both lines reference the EFI variable HibernateLocation-8cf2644b-4b0b-428f-9387-6d876050dc67 — the trailing UUID is systemd’s standard namespace UUID for this variable name and is identical on every Linux machine that uses systemd hibernation. What the two lines are telling you:
Clear Stale Hibernate Storage Info skipped, unmet condition check …(early in boot, ~25 s). Thesystemd-hibernate-clear.serviceunit ran, checked whether theHibernateLocationEFI variable existed, found it didn’t (because at that moment you hadn’t yet hibernated this session), and skipped. Not an error — it’s the unit correctly declining to do work when there’s nothing to clean up.efivarfs: removing variable HibernateLocation-…(later, however many minutes after boot you ransystemctl hibernate). The variable was written bysystemctl hibernate, persisted across the power cycle, read by the resume logic on next boot, and then removed by the kernel after the resume finished and the bookkeeping was no longer needed. This is the success signature of a complete hibernate + resume cycle.
If hibernation failed (image couldn’t be written, resume couldn’t find the image, the swap file wasn’t recognized), you’d see explicit PM: hibernation error lines mixed in with these — typically including the string failed, error, or Cannot find swap device. The absence of such lines is the proof the chain worked end-to-end.
The hibernation image lives in /swap/swapfile, which sits inside /dev/mapper/cryptroot — the unlocked LUKS volume. So the contents of your RAM at the moment you hit hibernate are encrypted on disk at rest, as a side-effect of where the swap file lives. There is no separate “hibernation password” — your LUKS passphrase is the only thing standing between a stolen laptop and the contents of RAM.
This gives hibernation the security posture of a fully-shut-down device while preserving “wake up where I left off” UX. Compare with the alternatives:
| Power state | RAM contents at rest | Cold-boot attack window |
|---|---|---|
| Shutdown | Empty (RAM is unpowered, contents lost) | None |
| Hibernate | Encrypted in swap file, inside LUKS | None |
| Suspend (S3) | Plaintext, still in powered RAM | Minutes — attacker can chill the chips, transplant, dump |
For a laptop that leaves your physical control (travel, conference rooms, anywhere you might want to close the lid and walk away with confidence), hibernate is meaningfully more secure than suspend.
What hibernation + LUKS does not protect against: someone walking up to your unlocked desktop session after you’ve successfully resumed. The LUKS prompt gates getting back into the machine; once that prompt is satisfied, you’re returned to whatever session state existed before you hibernated. If you didn’t have a screen-locker active at hibernate time, the unlocked desktop is sitting right there after resume.
For full two-factor coverage — LUKS to boot/resume, screen-lock to re-enter a session — pair this install with a screen locker once your chosen desktop is up. Whichever Part 4 path you take, install and configure a locker:
# Hyprland: hyprlock + hypridle (works under any Hyprland setup — Caelestia bundle or custom)
yay -S hyprlock hypridle
# GNOME / KDE / Sway / dwm — use the locker that matches your compositor.…then configure your locker to lock after N minutes of inactivity. With that in place, the threat model becomes: an attacker needs both the LUKS passphrase (to boot the disk) and the screen-lock passphrase (to re-enter your session). Without a locker running, only the LUKS prompt stands in their way.
Part 3.5 — Sanity check: confirm Parts 1–3 actually came up
Before you touch a desktop layer or anything else, prove the install you just did is real. Boot the laptop, log in at the TTY as your regular user, and run this block:
# Filesystem & encryption
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINTS
cryptsetup status cryptroot # active LUKS mapping
findmnt -t btrfs # all five Btrfs mountpoints (@, @home, @snapshots, @var_log, @swap)
sudo btrfs subvolume list / # same five subvolumes, listed by ID
# Swap + hibernation
swapon --show # zram0 (pri 100) + /swap/swapfile (pri -1)
zramctl # zstd-compressed zram device
# Snapshots — should now show at least the initial set from Part 3
sudo snapper -c root list
ls /.snapshots/ # numbered snapshot directories matching the list aboveWhat pass looks like:
lsblkshowsnvme0n1p2typedcrypto_LUKSwith a mappercryptroot(or whatever name you picked) sitting on top of it, typedbtrfs.cryptsetup status cryptrootprintsis active and is in usewithtype: LUKS2.findmnt -t btrfslists/,/home,/.snapshots,/var/log,/swap, all withsubvol=…andcompress=zstd:3.swapon --showshows zram at priority 100 plus your swap file at a lower priority.snapper -c root listhas at least one row.
If any line fails or returns empty: something in Parts 1–3 didn’t quite land. Don’t try to layer a desktop on top of that — fix the install first. The most common failure mode (kernel cmdline / mkinitcpio mistakes manifesting as an emergency-mode boot) has its own emergency section at the end of this article. If you’re stuck on something else (snapper config not found, a subvolume missing from findmnt), the chapter that set up the failing piece is usually the right place to re-read — Chapter 5 (fstab), Chapter 9 (mkinitcpio), Chapter 10 (GRUB), or Chapter 12 (snapper).
Only continue to Part 4 once every line above looks the way it should. The install layer is the foundation everything else sits on; a wobble here will compound the moment you start a desktop.
Part 4 — Pick your desktop layer
You now have a verified, encrypted, snapshot-able single-boot Arch system on the console. The next step — putting a graphical session in front of it — is its own choice, and I deliberately keep it out of this article so the install layer above stays desktop-agnostic. PipeWire audio, GPU drivers, the AUR helper, the login manager, the bar, the wallpaper layer, every keybind — none of that is required for the install to be considered “done.” It’s a separate body of work.
Two paths, neither obviously faster than the other in practice — pick by taste, not by “how quick is it”:
Caelestia (Hyprland Desktop) — From Bare Arch to a Working Bar walks through a single coherent dotfile bundle: a Quickshell-based status bar, themed terminal (foot), fish + starship, a curated tool set, the Material-You-style wallpaper-driven retheming, GPU drivers (Intel / AMD / NVIDIA / hybrid), the AUR helper, KDE-Frameworks apps under Hyprland, and SDDM. When it works on the first try, you have a polished Hyprland desktop in a couple hours. When it doesn’t, you have a caelestia-meta .SRCINFO parse failure, a missing wallpaper daemon, a hyprland.conf source = line pointing at a file the bundle doesn’t ship, and the SSH-vs-local-seat trap with hyprctl — every one of which I hit and document in Article 05’s recovery callouts. You also inherit the Caelestia project’s design opinions wholesale, and on older hardware the Quickshell bar adds measurable latency. Read my honest retrospective at the top of Article 05 before committing.
A from-scratch Hyprland setup built piece by piece — your own status bar choice (waybar, ironbar, eww, …), your own terminal, your own keybinds, your own theme — with no project-bundle layer on top. Every component is one you picked, and there’s no surprise opinion baked in. More upfront decisions, but no “now what?” moments when a bundle script fails halfway through and leaves you in a half-themed Hyprland. This is the path I’m taking on my next laptop. Article dashed in the upcoming section on the Tech Zone landing.
If you don’t know which to pick:
- Pick Caelestia if you want to see what a polished Hyprland desktop can look like as a reference point — even with a rough install, the finished product is a useful target — and you don’t mind inheriting opinions you’ll likely strip later.
- Pick Custom Hyprland if you’d rather own every line of
hyprland.conf/hyprland.luafrom day one, you have a clear idea of what you want, and you’d rather front-load the decision-making than discover a baked-in choice halfway through living in it.
Either way: take Article 05’s caveats as a warning, not a deterrent. The bundle is fine when it works. It’s just not a guaranteed fast path.
You only need this section if something has gone horribly wrong. Specifically: you rebooted into the new system after Part 3, GRUB loaded, the LUKS prompt accepted your passphrase, and then the kernel dropped into a systemd emergency shell with a Timed out waiting for device line followed by a couple of Dependency failed for /sysroot lines and You are in emergency mode.
This is the exact failure mode that bricked the first attempt at this install (Article 01). Don’t panic — your data is intact (the LUKS volume is still there, just not being mounted as root); you need to fix one of four typos and rebuild. If your install is not in emergency mode and the Part 3.5 sanity check passed, you can skip this section entirely.
If you land in emergency mode after the LUKS prompt, the failure mode is almost always one of:
<UUID>or<RESUME_OFFSET>left as literal text in/etc/default/grub. Re-read Chapter 10.- Mapper name mismatch between
rd.luks.name=…=cryptrootandroot=/dev/mapper/cryptroot. They have to match exactly. rootflags=subvol=@missing. Without it, the kernel mounts the top-level Btrfs volume, which has no/sbin/init.mkinitcpio.confleft as the defaults — nosd-encrypthook, nobtrfsmodule. Re-edit, re-runmkinitcpio -P.
To recover, boot the install USB again and re-enter the system:
cryptsetup open /dev/nvme0n1p2 cryptroot
mount -o subvol=@ /dev/mapper/cryptroot /mnt
mount /dev/nvme0n1p1 /mnt/boot
arch-chroot /mntThen fix the offending file, re-run mkinitcpio -P and grub-mkconfig -o /boot/grub/grub.cfg, exit, umount -R /mnt, cryptsetup close cryptroot, reboot.
If you want loud boot logs to see exactly what the kernel is waiting for, remove quiet and loglevel=3 from GRUB_CMDLINE_LINUX_DEFAULT temporarily and regenerate the GRUB config. Once it boots cleanly, re-run the Part 3.5 sanity check — that’s the official “all good” gate.
The Arch Linux logo is a trademark of the Arch Linux project and is used here under Arch Linux’s branding terms for editorial purposes only.