Arch laptop runbook
Plain Arch on a Framework 13 (or any UEFI x86-64 laptop). Manual install,
LUKS2 + ext4, systemd-boot, Hyprland. Substitute your own username
(ppalmer), hostname (prismo) and GitHub account where they appear.
Two boots, two stages. Stage 1 runs from the USB installer as root and builds
the machine; Stage 2 runs from the installed system as your user and restores
your home directory.
Stage 1 — From the USB installer
Run as root in the live ISO. Everything before arch-chroot runs in the live
ISO; everything after it runs inside the chroot until exit.
-
Boot the official Arch ISO. Verify firmware is UEFI:
cat /sys/firmware/efi/fw_platform_size # 64(If empty, the machine is BIOS and this plan needs GRUB instead of
systemd-boot.) - Network:
iwctl(wifi) or ethernet, thenping archlinux.org. timedatectl set-ntp true
Partition
Adjust the device name to your disk:
fdisk /dev/nvme0n1
# 1 GiB type "EFI System"
# rest type "Linux filesystem"
Encrypt and format
cryptsetup luksFormat --type luks2 /dev/nvme0n1p2
cryptsetup open /dev/nvme0n1p2 cryptroot
mkfs.fat -F 32 /dev/nvme0n1p1
mkfs.ext4 /dev/mapper/cryptroot
Mount and pacstrap
mount /dev/mapper/cryptroot /mnt
mount --mkdir /dev/nvme0n1p1 /mnt/boot
pacstrap -K /mnt base linux linux-firmware amd-ucode base-devel networkmanager nano
genfstab -U /mnt >> /mnt/etc/fstab
Configure inside the chroot
arch-chroot /mnt
ln -sf /usr/share/zoneinfo/<Region>/<City> /etc/localtime
hwclock --systohc
echo 'prismo' > /etc/hostname
Locale
sed -i 's/^#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen
locale-gen
echo 'LANG=en_US.UTF-8' > /etc/locale.conf
Skip it and every later pacman call emits locale warnings.
Bootloader
Bootloader first: bootctl install creates /boot/loader/, so neither config
file below can be written before it runs.
bootctl install
/boot/loader/loader.conf:
default arch.conf
timeout 7
console-mode max
editor no
/boot/loader/entries/arch.conf:
title Arch Linux
linux /vmlinuz-linux
initrd /amd-ucode.img
initrd /initramfs-linux.img
options rd.luks.name=<LUKS-UUID>=cryptroot root=/dev/mapper/cryptroot rw
The UUID is the LUKS container’s (cryptsetup luksDump /dev/nvme0n1p2, or
blkid line with TYPE="crypto_LUKS"). The options keyword is required.
Getting any of this wrong hangs at
A start job is running for /dev/mapper/cryptroot.
In /etc/mkinitcpio.conf, uncomment the HOOKS line that contains sd-encrypt,
then build the initramfs image:
# HOOKS=(base systemd autodetect microcode modconf kms keyboard sd-vconsole block sd-encrypt filesystems fsck)
mkinitcpio -P
User and sudo
useradd -m -G wheel -s /bin/bash ppalmer
echo '%wheel ALL=(ALL:ALL) ALL' > /etc/sudoers.d/10-wheel
chmod 0440 /etc/sudoers.d/10-wheel
visudo -c -f /etc/sudoers.d/10-wheel # always validate
passwd # root
passwd ppalmer
systemctl enable NetworkManager
visudo -c is not optional: a malformed sudoers file locks you out of sudo,
and on the laptop you would find out after the reboot.
Reboot
Exit, unmount, reboot, and remove the USB when it powers back up:
exit
umount -R /mnt
reboot
Stage 2 — From the installed system
Boot into Arch and log in at the TTY as your user. .bash_profile sources
.bashrc, which drops you into tmux; from here on everything is user-level.
Bootstrap deadlock
On a fresh machine chezmoi init cannot clone this repo, for two independent
reasons:
- The repo is private, so HTTPS needs credentials a fresh machine has no way
to supply. - There is no ssh client:
opensshis listed inpackages.yaml, which lives
in the repo you are trying to clone.
The dependency is circular (repo → packages.yaml → openssh → ssh → clone
repo) and has to be broken by hand:
sudo pacman -S --needed sudo git openssh chezmoi
SSH key
The one native-Linux difference: this machine holds its own key, not the
1Password vault.
ssh-keygen -t ed25519 -a 100 -C "ppalmer@prismo" -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub # register with GitHub as auth and signing key
systemctl --user start ssh-agent.socket # agent up before the clone
SSH_AUTH_SOCK=/run/user/1000/ssh-agent.socket ssh-add ~/.ssh/id_ed25519
run_onchange_after_20-ssh-agent.sh enables Arch’s socket-activated agent
(/usr/lib/systemd/user/ssh-agent.socket). AddKeysToAgent in ~/.ssh/config
loads the key on first use, so the passphrase is typed once per boot rather
than per connection.
ssh -T git@github.com # expect Hi <user>!
Note chezmoi init ppalms/dotfiles expands to an HTTPS URL. Use the explicit
SSH form.
Clone and apply
chezmoi init --apply git@github.com:ppalms/dotfiles.git --branch v2
Prompts for name, email and font size; pass --promptString for unattended
runs. Answers persist to ~/.config/chezmoi/chezmoi.toml, so re-running init
does not re-ask — which also means changing a default in .chezmoi.toml.tmpl
never reaches a machine that already answered. Edit that file directly to retune
a size. The font family is deliberately not a prompt for this reason; it lives
in .chezmoidata/font.yaml and propagates on a plain apply.
The apply installs the packages.yaml set via
run_onchange_before_10-packages.sh (sudo may prompt) and, on native Linux,
enables the socket-activated ssh-agent via run_onchange_after_20-ssh-agent.sh.
Linger
sudo loginctl enable-linger $USER
Without it /run/user/1000 is never created, XDG_RUNTIME_DIR is missing, and
the ssh-agent socket fails in a way that looks like an SSH problem while SSH
itself works fine.
Neovim first run
chezmoi apply does not provision Neovim. First launch clones nvim-treesitter
and compiles its parsers. Order matters: nvim-pack-lock.json must be on disk
before nvim first runs, which is why this comes after apply. Launch nvim
first and you silently get main HEAD instead of the pinned revision.
nvim
Explicit packages
sudo pacman -D --asexplicit diffutils
diffutils is already installed as an mkinitcpio dependency, so
pacman -S --needed skips it and it stays flagged as a dependency — a later
pacman -Rns could sweep it up.
Desktop
sudo pacman -S hyprland xdg-desktop-portal-hyprland xdg-desktop-portal \
waybar wofi hyprpaper hyprlock hypridle swaync grim slurp wl-clipboard \
brightnessctl playerctl network-manager-applet polkit-kde-agent \
qt6-wayland pipewire pipewire-pulse wireplumber blueman \
alacritty ttf-ibmplex-mono-nerd \
mesa vulkan-radeon libva-mesa-driver mesa-vdpau
The Hyprland config is managed in this repo (dot_config/hypr/) and was already
applied by the bootstrap step. The Hyprland project no longer recommends uwsm (Universal Wayland Session Manager),
so start it from the TTY:
start-hyprland
The config is Lua, not hyprlang (deprecated). Grow the config using
chezmoi edit ~/.config/hypr/hyprland.lua; add waybar/hyprpaper when the
bare config actually annoys you. Two consequences worth knowing before
debugging:
hyprland.luaandhyprland.confcannot both be active. The.luawins, and
the choice is made once at startup — so switching formats needs a full restart,
nothyprctl reload. Editing the Lua in place does reload on save.hyprctl configerrorslists parse errors, andhyprctl descriptionsreturns
every config option with its type for the build you actually have installed.
Prefer that over the wiki, which tracksmain.
The Gruvbox palette is centralized in .chezmoidata/gruvbox.yaml and renders
into alacritty, tmux and hyprland — edit it there, never in the configs.
Keyboard
Keyboard: type-test it. hyprctl will lie to you. The layout is Colemak-DH
from a custom XKB symbols file (dot_config/xkb/symbols/colemak_dhm, managed by chezmoi),
selected with kb_layout = "colemak_dhm" — not kb_variant, which cannot
reference a standalone file at all. Both hyprctl configerrors and hyprctl
getoption input:kb_layout report success while the layout is silently wrong, so
typing is the only real check.
kb_options = compose:caps makes CapsLock a Compose key. The sequences live in
~/.XCompose, restored by chezmoi apply. Test using CapsLock, then space,
then n — because a missing or empty file fails silently:
CapsLock starts a sequence with nothing to complete it and no error appears.
Verify
Confirm the verification commands exist before trusting their output — a missing
binary does not always fail loudly:
m=""; for c in diff cmp sha256sum jq awk; do command -v "$c" >/dev/null || m="$m $c"; done
[ -z "$m" ] && echo probes-ok || echo "MISSING:$m -- fix before trusting anything below"
Do not collapse that loop to command -v diff cmp ... — Bash exits 0 if any
one of several names resolves.
Then:
ssh-add -l # 1 (per-machine key)
ssh -T git@github.com # Hi <user>!
tmux -f ~/.config/tmux/tmux.conf start-server \; display -p '#{prefix}' \; kill-server
nvim --version | head -1 # 0.12.x
ls ~/.local/share/nvim/site/parser | wc -l # 15
[ -d /run/user/1000 ] && echo linger-ok
opencode --version # from pacman, /usr/bin/opencode
chezmoi diff # silent
The lockfile actually drove the install — the parser count above passes either
way, so this is the assertion that distinguishes “the committed lockfile
worked” from “landed on main HEAD and got lucky”:
want=$(jq -r '.plugins["nvim-treesitter"].rev' ~/.config/nvim/nvim-pack-lock.json)
got=$(git -C ~/.local/share/nvim/site/pack/core/opt/nvim-treesitter rev-parse HEAD)
[ "$want" = "$got" ] && echo "lockfile-ok $got" || echo "MISMATCH want=$want got=$got"
opencode is the check that packages.yaml provisions a tool end-to-end. If it
is missing, the package install did not complete — not a reason to reach for the
upstream curl installer. Claude Code, by contrast, is expected to be absent;
it is unpackaged and installs by hand: ./scripts/install-claude-code.sh.
Also verify native-specific items:
systemctl --user is-enabled ssh-agent.socket # enabled
cat /sys/firmware/efi/fw_platform_size # 64
First commit
Commits sign automatically via the SSH agent — git log -1 --format='%G?' should
return G. Register the key with GitHub as a Signing Key too: GitHub treats
authentication and signing keys separately, and the same key must be added under
both types or commits show Unverified. Every commit comes from WSL or the
native laptop; there is no Windows clone to commit from.
Before the first commit, register this machine’s key in allowed_signers so its
own commits verify locally (git log --show-signature would otherwise report
“No principal matched”). Replace the existing Arch line in
private_dot_ssh/allowed_signers.tmpl with:
{{ .email }} $(cat ~/.ssh/id_ed25519.pub)
(run that as a shell expansion, or just copy the output of
echo "{{ .email }} $(cat ~/.ssh/id_ed25519.pub)" into the file). This is a
per-reinstall step — every new key needs a new line. Then make the first signed
commit + push from the laptop.