Skip to main content

Arch + LUKS + Btrfs — Encrypted, Snapshot-Ready Single-Boot

arch linux
linux
installation
luks
btrfs
snapper
hibernation
grub
mkinitcpio
systemd
zram
Single-boot Arch Linux on an NVMe SSD: LUKS2 full-disk encryption, Btrfs with proper subvolumes (@, @home, @snapshots, @var_log, @swap), zram for everyday compressed swap, a NoCOW swap file on Btrfs for hibernation, snapper + grub-btrfs for bootable snapshots. A working, copy-pasteable expansion of my basic Arch install — with every correction from the failed-boot post-mortem already baked in. The desktop layer is deliberately a separate choice, see Part 4.
Author

Evanns Morales-Cuadrado

Published

May 17, 2026

What this is

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.

How to read the hardware-specific examples in this guide

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 lspci output, 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:

  1. systemd-based initramfs (sd-encrypt) instead of the older udev/encrypt hook. The kernel parameter format (rd.luks.name=<UUID>=<name>) is documented in systemd-cryptsetup-generator(8), accepts no : separator, and is harder to mistype than cryptdevice=.
  2. 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.
  3. 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.
  4. Snapper integration from day one, so the very first pacman -Syu already 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:

  1. Verify the ISO with sha256sum -c sha256sums.txt and gpg --verify.
  2. Boot the USB, confirm UEFI mode with cat /sys/firmware/efi/fw_platform_size (should print 64).
  3. Connect to Wi-Fi via iwctl, then systemctl enable --now sshd and passwd so you can SSH in from a real keyboard.
  4. 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.

Confirm what disk you’re about to wipe
lsblk -d -o NAME,SIZE,MODEL,TRAN

The 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/nvme0n1

Inside cfdisk:

  1. d on every existing partition until the table is empty.
  2. n → 1 GiB → type EFI System → that’s /dev/nvme0n1p1.
  3. n → accept the remaining space → type Linux filesystem → that’s /dev/nvme0n1p2.
  4. w, then type yes.

Verify:

lsblk

You 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/nvme0n1p2

It 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 cryptroot

From here on, the live system reads and writes /dev/mapper/cryptroot as a normal block device — LUKS handles the encryption transparently.

Why the mapper name matters

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/cryptroot

Mount 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 /mnt

Subvolume-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).
Names matter for tooling, not the kernel

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/boot

The 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 picks zstd:3 if you just say compress=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 \
    reflector

Use 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.
Two harmless warnings you will see during pacstrap

Both of these print in red but are expected and do not mean the install failed:

  1. 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 default us keymap (fine for most people); we set it properly in Chapter 6 and rebuild the initramfs in Chapter 9.
  2. fatal library error, lookup self after Performing snapper post snapshotssnap-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 /mnt
Sanity-check the generated fstab
cat /etc/fstab

Each 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/sudoers

Replace 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).

Substitute your timezone — don’t inherit mine

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/Tokyo

Then 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   # JST

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

What /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:

  1. Why a separate 127.0.1.1 instead of 127.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/8 is a /8 subnet (16 million addresses) entirely reserved for loopback, so there’s room to spare and 127.0.1.1 is also purely local — it never leaves your machine. Functionally either would work; conventionally, 127.0.1.1 is what you use.

  2. What does .localdomain mean? 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 like hostname -f (get the FQDN), gethostname(), and getfqdn() from various languages all walk /etc/hosts looking for an entry that names this host. With arch.localdomain arch on the third line, they all return arch.localdomain instead of failing or returning an empty string. If you ever actually join a domain (mycompany.internal, say), you’d change this to arch.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.

Already booted and want to change the hostname later?

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 arch

Then 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/hosts

Verify 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/swapfile

What that one command does for you:

  1. Creates a file at /swap/swapfile.
  2. Sets the NoCOW attribute (chattr +C) — required because copy-on-write swap files break in interesting and silent ways.
  3. Disables compression on the file.
  4. Preallocates extents (so the kernel can compute a stable offset for hibernation).
  5. Runs mkswap against it.
  6. With --uuid clear, leaves the swap UUID blank, which avoids a stale-UUID warning on first swapon after a fresh install.
Don’t 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/fstab

Find 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/swapfile

The -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 = swap

What 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 -P

A 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/nvme0n1p2

That 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>"
The placeholders are placeholders

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

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

Why we don’t need 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.service

What 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 --show

Here 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
Why there are two UUIDs in this output

Look closely — the GRUB line and the fstab lines reference different UUIDs:

  • rd.luks.name=d2c12564-…=cryptroot is 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 swapon –show line above

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
reboot

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

  1. Firmware → GRUB menu (Arch is the only entry; press Enter).
  2. Kernel + initramfs load from the ESP.
  3. systemd-in-initramfs prompts: Please enter passphrase for disk arch (cryptroot):.
  4. You type the LUKS passphrase, hit Enter.
  5. The mapper appears, the @ subvolume mounts as /, control hands off to the real systemd.
  6. 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/swapfile

Verify 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 below

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

Then 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 arch

Substitute arch everywhere with whatever hostname you actually picked.

Do you need to reboot?

The kernel hostname changed instantly, but two things still see the old value:

  • Your current shell cached $HOSTNAME at startup. Your prompt won’t update until you open a new shell — either log out and back in, or Ctrl-Alt-F2 to 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 reboot

After 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 loss
A few nmcli 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 true

Timezone: 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 took

The 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 — yes here 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 fstab

Tighten 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=0

Should 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 backuprestic 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
If you decide to enable it — use 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=0

snapper-timeline.timer and snapper-cleanup.timer are already enabled (from Chapter 11), so they automatically apply this config too.

Mark build / cache directories NoCOW regardless of whether you enable home snapper

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.

“If I’m not snapshotting /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.
Reflinkscp --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 list

What 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 -Syu and cleanup number. From here on, every upgrading pacman invocation gives you a rollback point.
  • If pacman -Syu printed there 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 -Syu syncs. You’ll only see snapshot 0 │ 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 pair

After 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 │ current is snapper’s reference placeholder, created when you ran snapper -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 │ pre was taken by snap-pac the instant before pacman started touching the filesystem. Description is the exact pacman command line. Cleanup is number, so this snapshot stays around until ten newer pre/post pairs exist.
  • Snapshot 2 │ post was taken the instant after pacman finished. Pre # = 1 ties 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 1

undochange 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 hibernate

The screen should go dark, the fans stop, the power LED dim. Press the power button to wake it. The expected sequence on resume:

  1. Firmware → GRUB → kernel + initramfs (same as a cold boot).
  2. sd-encrypt prompts for your LUKS passphrase — see the security-model callout below for why this is the only prompt you should see.
  3. After unlock, systemd-hibernate-resume.service reads 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 hibernate

Two 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). The systemd-hibernate-clear.service unit ran, checked whether the HibernateLocation EFI 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 ran systemctl hibernate). The variable was written by systemctl 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.

Hibernation + LUKS: what this gives you, security-wise

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 above

What pass looks like:

  • lsblk shows nvme0n1p2 typed crypto_LUKS with a mapper cryptroot (or whatever name you picked) sitting on top of it, typed btrfs.
  • cryptsetup status cryptroot prints is active and is in use with type: LUKS2.
  • findmnt -t btrfs lists /, /home, /.snapshots, /var/log, /swap, all with subvol=… and compress=zstd:3.
  • swapon --show shows zram at priority 100 plus your swap file at a lower priority.
  • snapper -c root list has 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”:

Path A → Caelestia: a Hyprland bundle (Article 05)

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.

Path B → Custom Hyprland (upcoming)

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.lua from 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.


🚨 Emergency: “Timed out waiting for device” at boot

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:

  1. <UUID> or <RESUME_OFFSET> left as literal text in /etc/default/grub. Re-read Chapter 10.
  2. Mapper name mismatch between rd.luks.name=…=cryptroot and root=/dev/mapper/cryptroot. They have to match exactly.
  3. rootflags=subvol=@ missing. Without it, the kernel mounts the top-level Btrfs volume, which has no /sbin/init.
  4. mkinitcpio.conf left as the defaults — no sd-encrypt hook, no btrfs module. Re-edit, re-run mkinitcpio -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 /mnt

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


Attribution

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.