Nix flake for my Macs (nix-darwin) and NixOS machines, with Home Manager for everything user-level. Built with flake-parts in the dendritic pattern and themed with Catppuccin Mocha.
flake.nix inputs; loads every module under modules/
justfile everyday commands (`just --list`)
modules/
├── flake/ plumbing: the `features` and `hosts` options, system builders
├── hosts/ one file (or directory) per machine
├── profiles/ what hosts select: platforms/, roles/, and the blocks/ they share
└── features/ everything a host can have, grouped by kind
scripts/ onboarding wizard and the helpers behind the justfile
A feature (features.<name>) keeps its darwin, nixos and homeManager parts in one file, plus the other features it includes. There are no enable flags: a feature is on when a host selects it, directly or through a profile.
Profiles are features that select other features and hold no settings of their own, except OS-only settings in a platform. A host is its platform plus any combination of roles:
| Type | Profiles | Rule |
|---|---|---|
profiles/platforms/ |
darwin, nixos |
Added from system; may hold settings only that OS has |
profiles/roles/ |
server, workstation, desktop |
Any combination: runs unattended, I work on it, I sit at it |
profiles/blocks/ |
base, fleet, development |
Shared by roles; a host may add one directly |
Roles overlap through fleet (maintenance, Tailscale, sshd), and a feature is selected once however many profiles include it. desktop lists both the macOS and future Linux desktop features; each one does nothing on the other OS.
A host (hosts.<name>) sets its platform, login, selected features and any machine-only config, under the same darwin, nixos and homeManager keys a feature uses. The name is both the flake target and the hostname.
# modules/hosts/work-macbook.nix
hosts.work-macbook = {
system = "aarch64-darwin";
login = "chenxin-yan";
features = with config.features; [ workstation desktop ]; # + darwin, from system
exclude = with config.features; [ podman ]; # an exception to the roles
darwin = { /* nix-darwin: state version, account */ };
homeManager = { /* Home Manager: machine-only packages */ };
};The machinery is in modules/flake/features.nix and modules/flake/hosts.nix. To see what a host runs: nix eval .#hosts.<name>.features --json.
- All machines: Nix with flakes enabled, and Git.
- macOS: an Apple Silicon Mac and the Xcode Command Line Tools (
xcode-select --install). Don't install Homebrew yourself: nix-homebrew manages it. - NixOS: already installed and booted, logged in as your normal user. Its
/etc/nixosconfig is kept, not replaced. - Secrets: an onboarded machine with 1Password, to enrol the new one.
nix-shell -p git --run 'git clone https://github.com/chenxin-yan/nix-dotfiles ~/dotfiles'
cd ~/dotfiles
bash scripts/onboard.shFor a new machine, the wizard writes modules/hosts/<name>/, keeping NixOS's own /etc/nixos config under _installed/. It then evaluates, builds and activates the machine, asking before every change. Before building, it prints a just secrets-enrol command to run on a machine with 1Password, and waits until that's pushed. On a reinstalled machine that already has a host file, it reuses that file instead. The stages are described at the top of scripts/onboard.sh. A new Mac gets workstation desktop; a new NixOS machine gets workstation, so add any other roles to the host file afterwards. Commit any new host files.
Raspberry Pi (pi): NixOS's installers can't boot a Pi 5 from NVMe, so it is installed by flashing its own image: build .#nixosConfigurations.pi.config.system.build.sdImage on an aarch64-linux builder (an Apple Silicon Mac alone is aarch64-darwin) and write it to the drive. Trust the nixos-raspberrypi.cachix.org cache on that builder first, or it compiles the vendor kernel itself; the Pi only gets that cache after its first switch. Before the first boot, with the drive still attached to the machine that flashed it, copy its enrolled host key pair to /etc/ssh/ssh_host_ed25519_key{,.pub} on the NIXOS_SD partition. Otherwise it generates a new key on boot and can't decrypt its secrets. Then onboard it like any other machine.
Nix can't sign in to accounts, approve macOS permissions or join networks. Do these on each machine, then check with just doctor.
Logins
- Desktops: sign in to 1Password, then in Settings → Developer (on a Mac,
open onepassword://settings/developers) turn on Use the SSH agent and Integrate with 1Password CLI. - Machines without 1Password's SSH agent: the wizard offers to give the login its own
~/.ssh/id_ed25519, declare it as the host'ssshKeyso the fleet accepts it, and, after activation, sign in withghand add it to GitHub. Commit and push the host file, thenjust switchthe other machines. To usejust secret*there, runop account addonce. gh auth login(unless the wizard did it) and the coding agents' own logins (pi, Claude Code, Codex).
Network
- Tailscale:
sudo tailscale upto join the tailnet. Keep the machine's Tailscale name auto-generated from its hostname;ssh <name>relies on it.
macOS permissions (approve in System Settings when prompted)
- Karabiner driver extension (for kanata): General → Login Items & Extensions.
- Input Monitoring and Accessibility for kanata, Accessibility for AeroSpace and espanso: Privacy & Security.
- Background items for sketchybar and the other agents: General → Login Items & Extensions.
| Command | What it does |
|---|---|
just switch |
Rebuild and activate this machine (checks it matches its host entry) |
just switch <target> |
Same, once, for a machine whose hostname doesn't match its target yet |
just update |
Update flake inputs |
just update-pins |
Update pinned fetchFrom* sources |
just clean |
Garbage-collect with this host's retention, then optimise the store |
just fmt |
Format Nix files |
just doctor |
Check this machine's secrets, SSH agent and GitHub access |
just secret-set <name> |
Set one secret from a hidden prompt |
just secrets-edit |
Edit secrets/shared.yaml in $EDITOR |
just secrets-enrol <name> <key> |
Let a machine decrypt secrets |
just secrets-rekey |
Re-encrypt every secrets file for the enrolled machines |
API keys are encrypted in secrets/. Each enrolled machine decrypts them at activation with its SSH host key; the just secret* recipes use the admin key, stored in 1Password as sops-admin.
-
Change a key:
just secret-set <name>, commit, thenjust switchon each machine. A key only one machine uses lives in its own file:just secret-set <name> secrets/hosts/<host>.yaml(e.g. the Pi'shermes-env). Dokploy's keys can't be changed this way; seemodules/features/services/dokploy.nix. -
Use one in a feature: include
secrets, declaresops.secrets.<name>.owner = host.login;in the feature'sdarwinandnixosparts, and have the program readosConfig.sops.secrets.<name>.pathwhen it runs. Never read the value during evaluation; it would end up in the Nix store. -
Enrol a machine: the onboarding wizard prints the command. By hand:
just secrets-enrol <name> '<key>'with that machine's/etc/ssh/ssh_host_ed25519_key.pub, then commit and push before its first switch. Re-enrolling replaces the old key. -
A machine is lost: delete its line from
modules/hosts/_host-keys.json, runjust secrets-rekey, commit, and rotate the API keys with their providers. -
The admin key is lost: create a new one. This prints only its public half; put it in
admininmodules/flake/sops.nix.nix shell nixpkgs#age -c sh -c 'age-keygen 2>/dev/null | op document create - --title sops-admin --file-name keys.txt >/dev/null && op document get sops-admin | age-keygen -y'Then re-encrypt from an enrolled machine, using its host key:
install -m 0644 "$(nix build --no-link --print-out-paths .#sops-config)" .sops.yaml key="$(sudo "$(command -v ssh-to-age)" -private-key -i /etc/ssh/ssh_host_ed25519_key)" for f in secrets/*.yaml secrets/hosts/*.yaml; do [ -e "$f" ] && SOPS_AGE_KEY="$key" sops updatekeys --yes "$f"; done unset key
Nix doesn't update these; check them every few months.
| Where | What | How |
|---|---|---|
| Macs | macOS and Mac firmware | System Settings → Software Update |
| Macs | Homebrew packages (activation installs but never upgrades) | brew update && brew upgrade |
| Machines with pi | pi's npm extensions, which aren't version-pinned | pi update --extensions |
| framework | BIOS and device firmware, through fwupd | fwupdmgr refresh && fwupdmgr update |
| minipc | BIOS (GEEKOM A6; not on fwupd) | Download from GEEKOM's support site and flash by hand |
| pi | Bootloader EEPROM, a flash chip outside any disk | From the dotfiles checkout, nix shell --inputs-from . nixpkgs#raspberrypi-eeprom -c sudo rpi-eeprom-update -a, then reboot. Its images come from the locked nixpkgs, so they may lag. |
| pi | Apps deployed through Dokploy and their images | Dokploy's UI. Dokploy, Traefik and PostgreSQL themselves are pinned by nix-dokploy. |
git addnew files before switching. Git-backed flakes don't see untracked files.- SSH accepts only keys declared in Nix. No passwords, no root, and
~/.ssh/authorized_keysis ignored. Every machine acceptsdesktopKeyinmodules/features/system/sshd.nixand each host'ssshKey, so any fleet machine reaches any other asssh <name>, with its host key pinned once enrolled. - Homebrew removes what isn't declared. Activation runs with
cleanup = "zap", so declare casks in the feature they belong to (darwin.homebrew.casks). - Some config is linked, not copied. Edits to the Neovim config (
modules/features/cli/nvim/config/) and the shell scripts behind the zsh aliases (modules/features/cli/zsh/scripts/) apply without a rebuild. nix flake checkonly evaluates the NixOS hosts. A broken Mac config shows up atjust switch.
Create a file under modules/features/<group>/ with the parts it needs:
# modules/features/gui/todoist.nix
{
features.todoist = {
darwin.homebrew.casks = [ "todoist-app" ];
homeManager = { pkgs, ... }: {
home.packages = pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.todoist-electron ];
};
};
}Then add it to a role's or block's includes, or to a host's features. Related features can nest: agents/ owns shared skills and agent tools, and includes the separate pi feature in agents/pi/; exclude = [ pi ] leaves the other agents available.
Add it to that host's exclude, as work-macbook does with podman.
Run the wizard, or copy an existing host file. Never reuse another machine's hardware configuration.
features.<name> is defined in the file with that name under modules/features/, e.g. features.tailscale → system/tailscale.nix. Profiles are the exception: features.workstation → modules/profiles/roles/workstation.nix. The theme is in theme.nix, and shared paths and environment variables are in paths.nix.