Declarative system configuration for a Framework 16 AMD 7040 (NixOS,
C40C04) and a macOS work laptop (nix-darwin, macOS-arm64).
Both machines share a common base of CLI tools, fonts, Zsh, Git, GPG, and Emacs tooling via Home Manager. Platform-specific layers sit on top.
This file might be most readable in an org-mode buffer directly. You
can also read it from GitHub, but it won’t render source block options
like :name or :tangle.
- Goals
- Learnt mistakes
- Why Nix
- Repository layout
- Getting started
- Day-to-day commands
- Testing changes
- Troubleshooting
- “experimental features nix-command flakes are not enabled”
- LUKS passphrase prompt uses wrong keyboard layout
- Home Manager: “file already exists”
- Secure Boot: “not in Setup mode”
- Nix store disk space
- Homebrew issues (macOS)
- Password change (NixOS)
- Fonts not showing up
- Secrets file
- Appendices
- (KISS principle) Keep the configuration simple, stupid: most systems work better when kept simple. No need to change mindset, no need to burden beginners with something abstruse. <<goal-0>>
- (principle of locality) Keep the configuration self-contained in one place. Different settings related to the same thing should be close to each other. No other, hidden data are needed for understanding and nothing else should produce side-effects. <<goal-1>>
- (predictability) The environment should keep the same behaviour no matter how many times the configuration is applied. When something goes wrong, roll back to a previous state. <<goal-2>>
- (declarativeness) Keep track of the reasoning which led to decisions and choices. The configuration is what and why, not how. <<goal-3>>
Non goal: making this file portable and auto-installable over the network on new computers.
- It would be rather useless: I work on one or two computers every day and don’t change that often.
- It would contradict the first principle. This is not an automatic installation script — write one if you want so.
- KISS principle
- Use Nix flakes to pin every dependency. The lock file is the
single source of truth; no imperative
nix-channelstate. - GUI apps on macOS stay managed by Homebrew through a
declarative
Brewfile: no point wrapping them in Nix derivations whenbrew bundleis idempotent already. - Emacs configuration lives in its own repository and is cloned
into
~/.emacs.d/. It has its own lifecycle.
- Use Nix flakes to pin every dependency. The lock file is the
single source of truth; no imperative
- Locality
- One repository for all hosts. A shared
home/base.nixholds every cross-platform setting; host-specific overrides live next to it. - Dotfiles and config snippets that can’t (or shouldn’t) be
expressed in Nix are kept in
config/and symlinked by Home Manager activation scripts.
- One repository for all hosts. A shared
- Predictability
nixos-rebuild switchanddarwin-rebuild switchare idempotent. The Nix store guarantees bit-for-bit reproducibility.- Every rebuild produces a numbered generation that can be rolled back to, or booted from.
- Use git to version this repository so you can always check out a prior state.
- Declarativeness
- Use git to keep track of changes in time. You can see
differences from the last commit as well as
git bisectwhen you don’t understand something weird. - Use literate documentation to explain all the whereabouts of the configuration.
- Use git to keep track of changes in time. You can see
differences from the last commit as well as
Lessons accumulated over several years of managing personal environments — first with raw dotfiles, now with Nix.
Why it’s not a good idea:
- Quite a lot of programs behave differently when they are in a git repository.
- It’s tedious to gitignore everything then un-ignore only specific
files. Last time I checked I ended up with a long, inexplicable
.gitignore. - Do you feel completely quiet doing
git bisectorgit reset --hardon your$HOME? Why not tryinggit clean -xin a deep subdirectory whilst you think it’s a repository but it isn’t?
Why it’s not a good idea:
- Is there any advantage of doing it that way in the context of a shell?
- It strongly separates the function from where it’s used.
- It can help create boring rookie bugs like fork bombs.
Why it’s not a good idea:
- Most GUI apps don’t refresh their environment once started.
launchctldefines the environment for all macOS applications (including the terminal emulator) and then you append additional variables in your shell startup files. It’s pretty useless to expose CLI variables to graphical applications.- The proprietary Apple environment APIs aren’t stable; stay the most Unix-like possible.
The ease of use is not interesting in front of the simplicity of use. Don’t automate overly, don’t add too much incidental complexity. Perhaps you want something awfully difficult and complex so it looks more professional.
I previously modified /etc/* and six months later I lost one full hour
because of an unwise side-effect. As a result, I got the strong
opinion that no side-effect should be defined out of the
configuration. Any code, data, or settings which are related to one
another should be physically close or in the same location.
In NixOS this lesson maps naturally to the module system: everything a
service needs is declared in one place, and nixos-rebuild is the only
way to apply it.
This repository is the successor of a literate macOS configuration
that lived as a single readme.org tangled with Emacs Babel. That
approach served well for years, but moving to NixOS made it clear that
a purpose-built declarative tool was a better fit.
The core appeal of Nix:
- Repeatable builds. A flake lock file pins every input to an exact revision. Two machines (or the same machine six months apart) produce identical results from the same lock — no “works on my laptop” drift.
- Declarative system configuration. A NixOS or nix-darwin rebuild
converges the entire system — packages, services, kernel parameters,
user environment — to the state described in a handful of
.nixfiles. Nothing is left implicit. - Atomic rollback. Every rebuild creates a generation that can be activated instantly. On NixOS the boot menu lists all recent generations, so a bad change never bricks the machine.
- Cross-platform. The same Home Manager modules run on NixOS and macOS (via nix-darwin), so shared tooling only needs to be written once.
The previous repository used org-mode literate programming: one giant
readme.org contained prose, shell snippets, and Emacs Lisp blocks that
were tangled into dotfiles with org-babel-tangle.
What worked:
- Prose and configuration lived side by side — decisions were documented where they were made.
- Org tags (
:macos:,:tty:,:language:) gave a navigable taxonomy. - Evaluating blocks in-place turned the readme into a REPL for system administration.
What didn’t scale:
- Tangling is a generation step, not an enforcement step. Nothing stopped the running system from drifting once the files were copied.
- Adding a new package meant editing prose, adding a
brew installline, and hoping you’d remember to tangle + copy. Nix makes the package list the source of truth and installs it atomically. - Cross-machine differences were handled with
ifguards inside shell blocks. Nix modules compose host-specific overlays cleanly. - The whole approach depended on Emacs being available. Bootstrapping a fresh machine required installing Emacs first, cloning the repo, and tangling — a fragile chain for disaster recovery.
The philosophical goals (locality, declarativeness, simplicity) haven’t changed; only the tool that enforces them has.
chezmoi is a polished dotfile manager. It handles templates, secrets,
and multi-machine diffs well. But it manages files, not system state.
It can place a .zshrc but cannot install Zsh, configure a systemd
service, partition a disk, or pin a kernel version. For a full-system
declaration, Nix is the right layer.
mise (formerly rtx) is a polyglot runtime manager — a modern
replacement for asdf, nvm, pyenv, and friends. It excels at
per-project tool versions but doesn’t attempt system configuration. On
NixOS the Nix store already provides hermetic, per-project tool
versions via nix develop / devenv / direnv, so mise would be a
redundant layer. On macOS it could complement Nix for teams not ready
to adopt flakes, but for a personal environment that already runs
nix-darwin it adds no value.
Both tools solve real problems. They simply solve different problems than the ones this repository addresses.
flake.nix # Flake entry-point — defines both hosts disko-config.nix # LUKS + LVM + Btrfs partitioning (C40C04) hardware-configuration.nix fonts.nix # Font packages shared across hosts hosts/ C40C04.nix # NixOS system config (GNOME, Secure Boot, …) macOS.nix # nix-darwin system config (macOS defaults, …) home/ base.nix # Cross-platform Home Manager (Zsh, Git, GPG, …) C40C04.nix # HM overrides for NixOS (Emacs, GUI apps, …) macOS.nix # HM overrides for macOS (Go, GitLab, Brewfile, …) config/ # Dotfiles and config snippets Brewfile # Homebrew casks & formulae (macOS only) before.zshenv # Sourced in ~/.zshenv after.zshrc # Sourced in interactive shells … packages/ # Custom Nix derivations (fonts) scripts/ # Activation helpers docs/ framework-16-nixos.org # Full NixOS install guide macos-nix-darwin.org # Full macOS setup guide
The flake defines two top-level outputs:
nixosConfigurations.C40C04: full NixOS system (kernel, GNOME, Secure Boot via Lanzaboote, LUKS via Disko, hardware tweaks fromnixos-hardware). Home Manager is loaded as a NixOS module.darwinConfigurations.macOS-arm64/darwinConfigurations.macOS-x86_64: nix-darwin system (macOS defaults, Nix daemon, shell registration). Home Manager is loaded as a darwin module. Both are produced by a singlemkDarwinHosthelper parameterised on the architecture.
Both hosts import home/base.nix (cross-platform CLI tools, Zsh, Git,
GPG, Starship, direnv, etc.) plus a host-specific overlay
(home/C40C04.nix or home/macOS.nix).
The flake pins two channels:
nixpkgs(stablenixos-25.11): system foundation.nixpkgs-unstable: bleeding-edge packages passed asunstableviaextraSpecialArgs.
On macOS, GUI apps are managed through Homebrew (config/Brewfile): the
Home Manager activation runs brew bundle install automatically.
Disk layout on C40C04 (disko-config.nix):
/dev/nvme0n1
├── ESP (1 GB, vfat) → /boot
└── LUKS (cryptroot)
└── LVM vg-C40C04
├── lv-swap (72 GB, swap, resume device for hibernate)
└── lv-btrfs (rest)
├── @root → /
├── @caocoa → /home/caocoa
├── @caocoa-cache → /home/caocoa/.cache
├── @caocoa-trash → /home/caocoa/.local/share/Trash
└── @.snapshots → /.snapshots
User accounts on NixOS use mutableUsers = false: passwords are managed
exclusively through hashed password files and nixos-rebuild.
Pick the guide that matches your situation:
- Framework 16: NixOS from Ubuntu live USB: boot a live USB, partition with Disko (LUKS + LVM + Btrfs), install NixOS, enrol Secure Boot keys, and clone your repos.
- macOS work machine: nix-darwin: install Nix + Homebrew + nix-darwin on a brand-new Apple Silicon Mac, build the flake, and set up your user environment.
"${HOME}"
├── .emacs.d/ (worktree of git repository)
│ ├── init.el (generated by init.org)
│ └── init.org (literate init.el)
├── Desktop
├── bin/ (binary files or executable scripts)
├── dist/ -> $HOME/.m2/repository/
├── img/ -> Pictures/
│ └── screenshots/ (where screenshots are put)
├── man/
├── mov/ -> Movies/
├── net/ -> Downloads/
├── pkg/
├── pvt -> Documents
│ └── todo/ (related notes and documents)
│ └── private-org-sync/
│ ├── library.org
│ └── todo.org
├── snd -> Music/
└── src/ (for source code)
├── github.com/ (host/username/repo)
└── …
I feel like it would really be a terrible idea to actually rename user folders like Documents and Movies because there are very standard folders which are not meant to change. Even with a standard macOS tool to say “OK, now let’s change the default folder for pictures from Pictures to img”, I can’t guarantee that no program wouldn’t blindly assume it exists.
I have chosen to hide default folders and create symbolic links to them so it looks like what I want but the change doesn’t bring too deep implication and weird bugs.
These file names are inspired from what golang expects.
# NixOS
sudo nixos-rebuild switch --flake .#C40C04
# macOS
darwin-rebuild switch --flake .#macOS-arm64# Update everything (nixpkgs, home-manager, disko, lanzaboote, …) nix
flake update
# Update a single input nix flake update nixpkgs nix flake update
home-managerThen rebuild and switch to apply.
Automatic weekly GC is configured (deletes generations older than 30 days). To trigger it manually:
sudo nix-collect-garbage --delete-older-than 14d
# Also remove old boot entries (NixOS only) sudo nixos-rebuild boot
--flake .#C40C04# NixOS system generations
sudo nix-env --list-generations --profile /nix/var/nix/profiles/system
# Home Manager generations
home-manager generations# NixOS — previous generation
sudo nixos-rebuild switch --rollback
# Home Manager — previous generation
home-manager switch --rollbackEvery nixos-rebuild switch or home-manager switch creates a new
generation, a snapshot of the entire system/user closure. Rolling back
activates the previous generation immediately. On NixOS, old
generations also appear in the boot menu, so you can recover even if
the latest generation fails to boot.
# NixOS — build only
sudo nixos-rebuild build --flake .#C40C04
# macOS — build only
darwin-rebuild build --flake .#macOS-arm64Compare the running system with the newly built closure to see which packages change:
# After `nixos-rebuild build`:
nix store diff-closures /run/current-system ./resultnix store diff-closures prints lines like:
python3: 3.11.8 → 3.11.9, +0.2 MiB
→ means an upgrade, + a new package, − a removal. This is the
quickest way to audit what a rebuild will change before committing to
switch.
nixos-rebuild build-vm --flake .#C40C04
./result/bin/run-*-vm- The VM gets its own disk image; it does not touch real filesystems.
- Log in as
caocoa. Because the real system useshashedPasswordFilepointing to/etc/secrets/caocoa-password(which does not exist inside the VM), you may need to temporarily addusers.users.caocoa.password = "test";in a local override. - The VM opens a QEMU window — you need a graphical session or VNC.
- Pass
-m 4096to give the VM more RAM if GNOME feels sluggish.
The tests/ directory contains bats-core test suites that cover the
Home Manager activation scripts in scripts/:
| File | Covers |
|---|---|
tests/home-layout.bats | scripts/home-layout.sh (cross-platform symlinks and directories) |
tests/C40C04/home-layout-C40C04.bats | scripts/home-layout-C40C04.sh (Dropbox redirects, Linux mov alias) |
tests/macOS/home-layout-macOS.bats | scripts/home-layout-macOS.sh (macOS mov alias, Finder chflags) |
Each test runs in an isolated mktemp -d sandbox; teardown() uses
${tmpdir:?} to guarantee rm -rf never receives an empty argument.
Commands inside the scripts under test are isolated from the real
filesystem by setting HOME to the sandbox directory. chflags is
intercepted by a fake binary prepended to PATH inside the sandbox.
To run the suite locally, bats is provided by unstable.bats in
home/base.nix (available on all machines after a rebuild):
# All suites
bats tests/
# One suite
bats tests/home-layout.bats
# TAP output (useful for piping into a formatter)
bats --tap tests/The suite also runs automatically in CI on every push and pull request
(test-scripts job in .github/workflows/cicd.yml).
Verify the flake evaluates without errors (no full build):
nix flake checkPass it inline:
nix --experimental-features "nix-command flakes" <command>Or persist it:
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.confRebuild and reboot:
sudo nixos-rebuild boot --flake .#C40C04 && sudo rebootconsole.useXkbConfig = true derives the console keymap from
services.xserver.xkb.layout = "fr" / variant = "oss".
console.earlySetup = true ensures this keymap is baked into the initrd
so it is available before the root filesystem is mounted, i.e. at the
LUKS passphrase prompt.
Home Manager backs up conflicting files as *.hm-backup:
# List backups
find ~ -name '*.hm-backup' -ls
# Remove them once verified
find ~ -name '*.hm-backup' -deleteIf HM refuses to proceed entirely, remove the offending file manually and re-run the switch.
- Reboot into UEFI firmware (F2).
- Clear/reset the Secure Boot keys → puts firmware in “Setup Mode”.
- Save and reboot; Lanzaboote auto-enrols its keys.
- Enter UEFI again and enable Secure Boot.
sudo btrfs filesystem usage /
sudo nix-collect-garbage --delete-older-than 7d
sudo nix store optimiseThe Btrfs subvolumes use compress=zstd. To see the actual
compression ratio:
sudo compsize /This shows how much physical space each subvolume occupies versus its logical size.
brew doctor # diagnose common problems
brew update && brew upgrade # refresh everything
brew reinstall --cask <name> # force-reinstall a broken cask
# Re-run the Brewfile manually
brew bundle install --file config/Brewfilepasswd is a no-op (mutableUsers = false). Change the hashed
password file and rebuild:
echo "new-password" | mkpasswd -m sha-512 -s \
| sudo tee /etc/secrets/caocoa-password > /dev/null
sudo nixos-rebuild switch --flake .#C40C04fc-list | grep -i iosevkaNixOS: Fonts are installed via Home Manager (home.packages) and
made available through fontconfig. Rebuild and log out / log in —
GNOME reloads fontconfig on session start.
macOS: nix-darwin copies font files to /Library/Fonts/Nix Fonts/.
macOS Core Text discovers them after darwin-rebuild switch. If a
font still doesn’t appear, try restarting the app or running:
sudo atsutil databases -remove # reset font cachesTokens are loaded from ~/.zshenv.secrets (sourced in every Zsh
session). This file is gitignored — create it manually:
cat > ~/.zshenv.secrets << 'EOF'
export GITLAB_PERSONAL_ACCESS_TOKEN='glpat-…'
# add other tokens here
EOF
chmod 600 ~/.zshenv.secretsSome aspects of these other tools are interesting. Some aren’t as friendly; some are about deploying packages when the goal here is only to document and ease environment configuration.
What this repository uses now. The module system and flakes give declarativeness and reproducibility for free: no literate tangling step needed. The trade-off is a steeper learning curve and a language (Nix) that takes getting used to.
http://dotfiles.github.io/
A sane way to manage symlinks when you don’t want a full-blown configuration manager.