Skip to content

piotr-yuxuan/public-environment-configuration

Repository files navigation

Literate environment configuration

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.

Table of contents

Goals

  1. (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>>
  2. (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>>
  3. (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>>
  4. (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.

Choices made to reach these goals

  1. KISS principle
    • Use Nix flakes to pin every dependency. The lock file is the single source of truth; no imperative nix-channel state.
    • GUI apps on macOS stay managed by Homebrew through a declarative Brewfile: no point wrapping them in Nix derivations when brew bundle is idempotent already.
    • Emacs configuration lives in its own repository and is cloned into ~/.emacs.d/. It has its own lifecycle.
  2. Locality
    • One repository for all hosts. A shared home/base.nix holds 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.
  3. Predictability
    • nixos-rebuild switch and darwin-rebuild switch are 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.
  4. Declarativeness
    • Use git to keep track of changes in time. You can see differences from the last commit as well as git bisect when you don’t understand something weird.
    • Use literate documentation to explain all the whereabouts of the configuration.

Learnt mistakes

Lessons accumulated over several years of managing personal environments — first with raw dotfiles, now with Nix.

$HOME as a git repository for configuration files

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 bisect or git reset --hard on your $HOME? Why not trying git clean -x in a deep subdirectory whilst you think it’s a repository but it isn’t?

Each shell function has its own $HOME/bin file

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.

(macOS) Use launchctl setenv for environment variables

Why it’s not a good idea:

  • Most GUI apps don’t refresh their environment once started.
  • launchctl defines 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.

Use nuclear bombs to kill a mosquito

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.

Spread related settings

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.

Why Nix

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 .nix files. 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.

Evolving from Emacs org-mode

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 install line, 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 if guards 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.

Why not chezmoi or mise

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.

Repository layout

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

Architecture overview

The flake defines two top-level outputs:

  • nixosConfigurations.C40C04: full NixOS system (kernel, GNOME, Secure Boot via Lanzaboote, LUKS via Disko, hardware tweaks from nixos-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 single mkDarwinHost helper 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 (stable nixos-25.11): system foundation.
  • nixpkgs-unstable: bleeding-edge packages passed as unstable via extraSpecialArgs.

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.

Getting started

Pick the guide that matches your situation:

Organisation of $HOME

"${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.

Day-to-day commands

Rebuild after editing

# NixOS
sudo nixos-rebuild switch --flake .#C40C04

# macOS
darwin-rebuild switch --flake .#macOS-arm64

Update flake inputs

# Update everything (nixpkgs, home-manager, disko, lanzaboote, …) nix
flake update

# Update a single input nix flake update nixpkgs nix flake update
home-manager

Then rebuild and switch to apply.

Garbage-collect old generations

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

List generations

# NixOS system generations
sudo nix-env --list-generations --profile /nix/var/nix/profiles/system

# Home Manager generations
home-manager generations

Roll back

# NixOS — previous generation
sudo nixos-rebuild switch --rollback

# Home Manager — previous generation
home-manager switch --rollback

How rollback works

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

Testing changes

Build without switching

# NixOS — build only
sudo nixos-rebuild build --flake .#C40C04

# macOS — build only
darwin-rebuild build --flake .#macOS-arm64

Diff before switching

Compare the running system with the newly built closure to see which packages change:

# After `nixos-rebuild build`:
nix store diff-closures /run/current-system ./result

Reading the diff output

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

Test in a VM (NixOS only)

nixos-rebuild build-vm --flake .#C40C04
./result/bin/run-*-vm

VM testing tips

  • The VM gets its own disk image; it does not touch real filesystems.
  • Log in as caocoa. Because the real system uses hashedPasswordFile pointing to /etc/secrets/caocoa-password (which does not exist inside the VM), you may need to temporarily add users.users.caocoa.password = "test"; in a local override.
  • The VM opens a QEMU window — you need a graphical session or VNC.
  • Pass -m 4096 to give the VM more RAM if GNOME feels sluggish.

Shell script unit tests (bats)

The tests/ directory contains bats-core test suites that cover the Home Manager activation scripts in scripts/:

FileCovers
tests/home-layout.batsscripts/home-layout.sh (cross-platform symlinks and directories)
tests/C40C04/home-layout-C40C04.batsscripts/home-layout-C40C04.sh (Dropbox redirects, Linux mov alias)
tests/macOS/home-layout-macOS.batsscripts/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).

Flake evaluation check

Verify the flake evaluates without errors (no full build):

nix flake check

Troubleshooting

“experimental features nix-command flakes are not enabled”

Pass 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.conf

LUKS passphrase prompt uses wrong keyboard layout

Rebuild and reboot:

sudo nixos-rebuild boot --flake .#C40C04 && sudo reboot

How the keyboard layout reaches the initrd

console.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: “file already exists”

Home Manager backs up conflicting files as *.hm-backup:

# List backups
find ~ -name '*.hm-backup' -ls

# Remove them once verified
find ~ -name '*.hm-backup' -delete

If HM refuses to proceed entirely, remove the offending file manually and re-run the switch.

Secure Boot: “not in Setup mode”

  1. Reboot into UEFI firmware (F2).
  2. Clear/reset the Secure Boot keys → puts firmware in “Setup Mode”.
  3. Save and reboot; Lanzaboote auto-enrols its keys.
  4. Enter UEFI again and enable Secure Boot.

Nix store disk space

sudo btrfs filesystem usage /
sudo nix-collect-garbage --delete-older-than 7d
sudo nix store optimise

Btrfs compression ratio

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

Homebrew issues (macOS)

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

Password change (NixOS)

passwd 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 .#C40C04

Fonts not showing up

fc-list | grep -i iosevka

Platform-specific font troubleshooting

NixOS: 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 caches

Secrets file

Tokens 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.secrets

Appendices

Other tools to manage configuration

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

Nix and NixOS

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.

GitHub dotfiles

http://dotfiles.github.io/

GNU Stow

http://brandon.invergo.net/news/2012-05-26-using-gnu-stow-to-manage-your-dotfiles.html

A sane way to manage symlinks when you don’t want a full-blown configuration manager.

vcsh and myrepos

https://blog.tfnico.com/2014/03/managing-dot-files-with-vcsh-and-myrepos.html

Ansible, Puppet, Chef

https://blog.palcu.ro/2014/06/dotfiles-and-dev-tools-provisioned-by.html

About

Get a reproductible and declarative configuration of the programs I use for my daily life and document their use

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors