One flake for every machine
My dotfiles repo builds a Raspberry Pi, a Linux laptop, two Macs and a VM, and the machines are not special cases. What the flake looks like, why no file imports another, and what that costs.
The moment you own two machines
Part 1 ended with my aliases coming out of one file, and one machine is easy to describe: a repo, some config, a script that installs it. The trouble starts with the second machine, because now the repo has to say which machine it is building for.
The usual answers are a folder per machine with the same files copied into it, a branch per machine, or a config where every file is imported by hand at the top and you keep that list honest by remembering to. All three end up the same way: two machines that are meant to be identical are not, and you find out by using them.
What I do instead: one repo, one flake, one folder per machine, and one flake.lock that pins the versions for all of
them.
The whole flake
This is the end of flake.nix in my dotfiles repo, and it is the part that makes the rest work:
outputs =
{ self
, nixpkgs
, flake-parts
, import-tree
, ...
}@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.flake-parts.flakeModules.modules
(import-tree ./modules)
(import-tree ./hosts)
(import-tree ./flake)
];
};flake-parts gives the flake an options system, and import-tree walks a directory and imports every .nix file in
it. So import-tree ./modules means “everything under modules/ is part of this flake”, and there is no
imports = [ ./a.nix ./b.nix ] list anywhere in the repo to fall out of date.
Adding a module is adding a file. Renaming one is renaming a file. The repo cannot get out of sync with itself, because nothing is keeping a list of what is in it.
What a machine looks like
Machines live in hosts/, one folder each. This is the Raspberry Pi, cut off where its own hardware starts:
{ config, inputs, ... }:
let
hostname = "nixberry";
in
{
flake.nixosConfigurations.${hostname} = inputs.nixos-raspberrypi.lib.nixosSystem {
inherit (inputs) nixpkgs;
specialArgs = {
inherit inputs;
self = config.flake;
# NOTE: this is needed
inherit (inputs) nixos-raspberrypi;
};
modules = builtins.attrValues config.flake.modules.nixos ++ [Read the last line first. builtins.attrValues config.flake.modules.nixos is every NixOS module in the repo, all of
them, and the list after ++ is what this machine adds on top: the Pi 400’s kernel, firmware and binary caches. Every
machine starts from everything, and then says what it is.
The Mac I use for work is where “every machine” stops being a figure of speech:
let
hostname = "rebel-pieter";
system = "aarch64-darwin";
in
{
flake.darwinConfigurations.${hostname} = inputs.nix-darwin.lib.darwinSystem {
inherit system;
specialArgs = {
self = config.flake;
};
modules = builtins.attrValues config.flake.modules.darwin ++ [
./_usersTwo things changed. It builds a darwinConfiguration with nix-darwin (a separate project that configures macOS with
Nix; my Linux machines are nixosConfigurations) and it is aarch64-darwin instead of x86_64-linux or
aarch64-linux. Everything else is the same shape, and further down the same file the differences are visible as
switches:
modules = {
profiles.full.enable = true;
package-management.determinate.enable = true;
security.sops.enable = false;
};That machine wants the full desktop profile and does not want secret management. The Pi wants the opposite. Same modules, different answers, and neither machine had to know the other exists.
Building one is nixos-rebuild switch --flake ~/dotfiles#nixberry or
darwin-rebuild switch --flake ~/dotfiles#rebel-pieter. One repo, one lock file, five machines (and the VM at Hetzner
is the sixth, from the other half, which I get to at the end).
Why nothing imports anything
This is the dendritic part, and it is the bit people ask about.
A module here does not get imported into a machine. It registers itself on the flake instead. The first line of my starship file:
{
flake.modules.homeManager.starship = { config, lib, ... }:flake.modules.homeManager.starship is an attribute on the flake. The middle word says which kind of system the
module is for: the Linux machines collect flake.modules.nixos, the Macs collect flake.modules.darwin, and
homeManager is for home-manager, the tool that configures a user’s home directory (dotfiles, user packages, shell
settings) on top of Nix. The host collects everything under those attributes, which is the builtins.attrValues from
the previous section.
So a file says what it is, a host says which kinds it wants, and no file ever needs a list of the others.
The payoff is that a machine stays honest. There is one place a module can come from and one place it gets switched on, so there is no override to hunt for when one machine behaves differently than another.
The cost is real, and it is mostly in the mistakes you can make:
- Names. The convention has to hold everywhere, or a module is silently missing from every machine. Nothing errors at the point where you get it wrong, because a module that nothing collects is simply not there.
- Errors. When a module is missing, you get an error about an option that does not exist, in a machine file that never mentioned it, and the trace points into library code.
- Recursion. A module cannot compute its own top-level attribute names from the config, because the config is still
being built while those names are being read. Everything a module contributes has to be inside an option value. This
is the trap behind
error: infinite recursion encountered, and the fix is nearly always moving the value one level deeper. - Documentation. The pattern is young: a wiki, a few posts, and other people’s repositories. A lot of what is in this repo started as someone else’s public config.
The second flake
One more piece, because the repo count is part of the design: dotfiles builds machines, and my private repo builds on top of it. For the Pi, my private repo does not describe a machine at all, it extends the finished one:
flake.nixosConfigurations.nixberry = inputs.dotfiles.nixosConfigurations.nixberry.extendModules {
modules = [
# The tailnet firewall gates on declared ports, so the way in is one of
# them (the dotfiles base host pulls the module in).
{ tailnet.openPorts.nixberry."22" = "sshd, the way in from the tailnet"; }
config.flake.modules.nixos.nixberry-private
# No tailnet, libretro, alloy or node-exporter module here: the dotfiles
# base host already pulls in every module of its own flake, those included.
];The public machine is complete on its own. Anything on top of it that cannot be public (my secrets, the tailnet, the controller and audio settings that only exist because of my exact hardware) gets layered on here. Part 4 is about where that line is and why it moved more than once.
What it buys
- One set of versions. Every machine resolves its packages from one
flake.lock. The Pi and the VM run the same nixpkgs, so “it works on the laptop” means something. - A new machine is small. A folder in
hosts/, a handful of switches, and it inherits everything else. - The same module serves different systems. macOS and Linux is one file with a different collector, not a fork.
- You can read it. What a machine is, entirely, is that one file plus modules that all have the same shape. Which is part 3.
Next
Every file has the same two parts, and the shapes repeat so much that a file from two years ago still reads easily. How the options system makes that work, and the rules that keep it that way.