pieterpel
← All writing
Dotfiles on Nix · 2 of 4

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.

Published 2026-10-04 5 min read #nix #dotfiles #flake-parts

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:

source · flake.nix:116-130 view on GitHub ↗
  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:

source · hosts/nixberry/default.nix:1-15 view on GitHub ↗
{ 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:

source · hosts/mac/default.nix:6-18 view on GitHub ↗
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 ++ [
      ./_users

Two 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:

source · hosts/mac/default.nix:31-35 view on GitHub ↗
          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:

source · modules/terminal/starship.nix:1-2 view on GitHub ↗
{
  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:

source · flake/nixberry/nixberry.nix:10-18
  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.