pieterpel
← All writing
Dotfiles on Nix · 3 of 4

Every file the same shape

What is actually inside one module: the two parts every file has, why the option is the interface, and how a machine ends up being a list of switches.

Published 2026-10-11 4 min read #nix #dotfiles #home-manager

The shape, once

Part 1 was one thing described in one file. Part 2 was every machine starting from everything and switching on what it needs. This part is what is inside the files, because that is where the consistency lives: every module in the repo has the same two parts, in the same order, with the same names.

The smallest module I have is about delta, the diff viewer for git:

source · modules/terminal/delta.nix:1-20 view on GitHub ↗
let
  parent = "terminal";
  module = "delta";
in
{
  flake.modules.homeManager.${module} =
    { config
    , lib
    , pkgs
    , ...
    }:
    let
      cfg = config.modules.${parent}.${module};
    in
    {
      options.modules.${parent}.${module} = {
        enable = lib.mkEnableOption "Enable ${parent}:${module} configuration.";
      };

      config = lib.mkIf cfg.enable {

Seven of those twenty lines are the same in all ninety modules. The rest is what makes this one delta.

Reading it from the top:

  • flake.modules.homeManager.${module} = registers the module (part 2), and homeManager says it configures a home directory rather than a system.
  • options.modules.${parent}.${module}.enable declares one switch, with lib.mkEnableOption writing the “do you want this?” text for me.
  • config = lib.mkIf cfg.enable { ... } is the actual delta settings, and lib.mkIf is what keeps them from being applied when the switch is off.

So a module is a question and an answer. The question is an option, the answer is config, and nothing in between.

The names carry the structure

Two variables at the top explain the naming:

let
  parent = "terminal";
  module = "delta";
in

From those two words the file builds flake.modules.homeManager.delta (the registration), and config.modules.terminal.delta (the option to read) and options.modules.terminal.delta (the option to write). Four names, one source.

They also mirror the path: this file is modules/terminal/delta.nix, and the option is modules.terminal.delta.enable. So when I am looking for the option that turns something on, I do not have to look it up in a machine file or in a docs page, I can derive it from where the file is. That is what “be consistent” means in practice: the consistency is not a rule I have to remember while writing, it is built out of two strings.

The one exception is a module that should always be on. My aliases file has no enable option at all, it just is. The rule is not “every module needs a switch”, it is “a module that can be off declares how”.

Your own options, not just other people’s

The options I read in those files (config.modules.terminal.delta.enable) are mine. NixOS, home-manager and every other project in the flake bring their own option trees; this is a fourth one, in my own words, for my own things.

It is worth doing even for things that feel like constants:

source · modules/home/options.nix:5-32 view on GitHub ↗
  flake.modules.homeManager.options = {
    options = {
      browser = lib.mkOption {
        type = lib.types.str;
        default = "brave";
        description = "Default browser";
      };
      explorer = lib.mkOption {
        type = lib.types.str;
        default = "thunar";
        description = "Default file explorer";
      };
      terminal = lib.mkOption {
        type = lib.types.str;
        default = "kitty";
        description = "Default terminal";
      };
      editor = lib.mkOption {
        type = lib.types.str;
        default = "nvim";
        description = "Default editor";
      };
      stateVersion = lib.mkOption {
        type = lib.types.str;
        default = "24.11";
        description = "Default stateVersion";
      };
    };

That gives the rest of the repo a vocabulary. config.editor is nvim on my machines, and any module that needs an editor reads the option instead of hardcoding a name. Change the default and every module that reads it follows, including the ones I will write next year.

There is a version of this that is annoying: declaring an option for something one machine uses once is more code than the value. In practice, anything two places need, or that a machine would plausibly answer differently, gets an option. Everything else is just a value.

A profile is a module that switches on modules

Which brings the machines from part 2 back, because they are not the only thing setting options. Profiles are modules, and all they do is turn other modules on:

source · modules/profiles/full/full.nix:5-32 view on GitHub ↗
let
  defaultEnable = {
    enable = lib.mkDefault true;
  };
  defaultDisable = {
    enable = lib.mkDefault false;
  };

  nixosDefaults = {
    modules = {
      de.gnome = defaultEnable;
      gaming.steam = defaultEnable;
      gui.thunar = defaultEnable;
      home."home-manager" = defaultEnable;
      package-management.nix = defaultEnable;
      security.sops = defaultEnable;
      system = {
        boot = defaultEnable;
        configuration = defaultEnable;
        fonts = defaultEnable;
        internationalization = defaultEnable;
        networking = defaultEnable;
        printing = defaultEnable;
        tailscale = defaultEnable;
        sound = defaultEnable;
        updating = defaultEnable;
      };
      theming.stylix = defaultEnable;

lib.mkDefault true is the interesting part. It says “on, unless someone says otherwise”, so the profile is a starting point rather than a decision. A machine that imports the full profile can still turn one thing off, because a later, more specific value wins over a default. That is how five machines share one profile without any of them forking it: the profile answers for all of them, the machine file answers for the exceptions.

Next to this list in the same file there is a darwinDefaults for the Macs and a homeManagerDefaults for the home directories, so “what a laptop gets” is stated once per kind of system instead of once per machine.

What it costs

The two-part shape is boilerplate, and I type those eight lines more often than any other eight lines in the repo. A module that does one thing in one place is longer than the config it holds. I still write it that way, because the alternative is that the next reader (me, in a year) has to read the body to find out whether the thing is configurable.

The other cost is naming. A file whose path and option path disagree will still build, and I will find out months later when I cannot find the switch. Part 2’s first trap, in a smaller size.

If you are not going dendritic

You can keep the shape without the pattern. In a normal NixOS or home-manager config, one file per thing, an enable option at the top, and lib.mkIf cfg.enable around the config is most of the benefit: a machine file that reads as a list of switches, and modules you can turn off without deleting them.

Next

Part 4 is the one this series has been circling: what goes in the public repo and what does not, and why I moved pieces across that line more than once.