How do I modularize configuration snippets to modules?

I should start out with saying that I have a bit of a distaste for the various NixOS frameworks. They attempt to abstract away the NixOS module system without eliminating it, forcing you to learn two abstraction systems at the same time and making the whole thing more confusing because it’s yet another step removed from the actual deployment. On top of all of that, they add a significant switching cost if you ever want to stop using a specific framework, add a layer of relying on third party maintenance that may or may not disappear, etc.

So overall I’d suggest just not touching those frameworks (including snowfall lib), at least until you’re comfortable enough with the NixOS module system to understand what the frameworks are doing. Once you have that, you can use a framework to abstract away that understanding if you so desire, but I’ve not felt a need to do so at all with NixOS personally.

It’s like with all software engineering, if you tried to learn React before you understood HTML you’d probably struggle. And then React only makes sense because JS lacks a good way to map data to UI changes without painful micro-management of the DOM - if you use one for any reason but solving such painpoints, using a framework probably means you’re lacking some fundamental underlying understanding.

This is a very generic question. The same question probably applies to all programming in languages where multi-file declarations exist. IME letting these things happen organically over time often results in the best organization; you’ll go through several cycles of refactoring and eventually arrive at something sane.

You might be afraid that you’ll be painting yourself into a corner, but refactoring NixOS modules is really easy, simple NixOS usecases end up with painfully simple code, and there is no state to be afraid of screwing up. You also appear to be working on personal configuration, so you have no API to be worrying about. There’s absolutely zero reason to worry about splitting your modules if you don’t know how you should split them yet.

So for simple system configs, most things can just be in a big configuration.nix, especially initially. As your config grows you’ll naturally find things that feel like they belong together, and you can then break those things out into separate files. I’ll give some examples from my personal experience after explaining the rest, but that’s just my experience - I think module organization is entirely a matter of taste.

To start out, I’d recommend creating just a single configuration.nix with your general system config, and then a separate directory for each machine you use. Give each machine directory a default.nix where you place the various configuration that should only occur on that machine, and put the hardware-configuration.nix that you generate for each machine on install in the same directory.

I’ll get to how you build this up into a set of configurations you can build in a sec (by answering your other question).

Funnily enough, that already is a module! If you’re using a flake, you can literally put that in the modules arg of the nixosSystem function:

# flake.nix
{
  # <imagine some kind of inputs>
  outputs = { nixpkgs, ... }: {
    nixosConfigurations.yourcomputer = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux"; # For example
      modules = [({
        boot.loader = {
          grub = {
            enable = true;
            efiSupport = true;
            device = "nodev";
          };

          efi = {
            canTouchEfiVariables = true;
          };
        };
      })];
    };
  };
}

Of course, it would be a bit messy to put all your modules in-line in your flake.nix like this (though at times it can be useful to do so, some people add their home-manager modules like this). So you can just write that exact code in a separate file - say ./boot.nix - and tell nixosSystem to load said file:

# flake.nix
{
  # <imagine some kind of inputs>
  outputs = { nixpkgs, ... }: {
    nixosConfigurations.yourcomputer = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux"; # For example
      modules = [
        ./boot.nix
      ];
    };
  };
}

Modules aren’t magic - they are just attribute sets (JSON objects, if that helps you) that the NixOS module system merges together. There are some rules to this merge for what happens when options are declared multiple times (e.g. lists will be concatenated), but for the most part you don’t need to know much about it other than that it happens.

So, if we wanted to build the setup I outlined earlier, you could do this:

# flake.nix
{
  # <imagine some kind of inputs>
  outputs = { nixpkgs, ... }: {
    nixosConfigurations = {
      yourcomputer = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux"; # For example
        modules = [
          ./configuration.nix
          # When specifying a directory, NixOS will automatically read the
          # `default.nix` from it if it exists, similar to e.g.
          # `__init__.py` in the python world
          ./hosts/yourcomputer
          ./hosts/yourcomputer/hardware-configuration.nix
        ];
      };
      
      yourothercomputer = nixpkgs.lib.nixosSystem {
        system = "aarch64-linux";
        modules = [
          ./configuration.nix
          ./hosts/yourothercomputer
          ./hosts/yourothercomputer/hardware-configuration.nix
        ];
      };
    };
  };
}

To install yourothercomputer’s config, you could then just:

nixos-rebuild switch --flake .#yourothercomputer

It’d get tedious to have to duplicate all the modules you want to import between both machines, though. This is where you start using the NixOS module system. From a module, you can import other modules:

# configuration.nix
{
  imports = [
    ./boot.nix
  ];
}

This just instructs nix to merge together these two modules. In fact, we can use this to abstract away the need to put a reference to hardware-configuration.nix in flake.nix:

# hosts/yourothercomputer/default.nix
{
  imports = [
    ./hardware-configuration.nix
  ];
}

Now, sometimes you’ll find that a specific machine needs to deviate from something in the global configuration.nix, but you don’t want to make that one thing machine-specific across all machines to avoid code duplication. This is where you’ll need to use lib.mkDefault, lib.mkForce or lib.mkOverride:

# hosts/yourothercomputer/default.nix
{
  imports = [
    ./hardware-configuration.nix
  ];

  # Grub doesn't work on this motherboard
  boot.loader.grub.enable = lib.mkForce false;
  boot.loader.systemd-boot.enable = true;
}

And there we go, a fully functional, modular, multi-system NixOS config without any crazy frameworks! I’m sure you can see how you would port your current directory structure to this - and it’d be all organic and actually match how you understand the units of your configuration.

Looking at your directory structure, I’d personally place these files into the machine-specific directories:

  • disko-config.nix
    • Partition management will likely be system-specific for “normal” computers (i.e. not kubernetes cattle stuff)
  • hardware-configuration.nix
    • This is always going to be system-specific, by definition
  • networking.nix
    • Your link names will likely be system-specific because of predictable interface names, so unless you have no link-specific configuration this probably needs to be separate too

That’s just guessing contents, though, you’ll know better what actually is system-specific - and you might not even know until you get at least one other system.


For me personally, I’ve noticed breaking things out of configuration.nix that may be useful across multiple machines, but not necessarily all machines, is very useful. E.g., my nvidia configuration is a complete mess because nvidia is nvidia, and as such every nvidia system I will ever own will want a copy of it, but other machines of course will not.

Placing that in every machine-specific directory would be a lot of code duplication - so this should be factored out into a ./nvidia.nix, which specific systems can then add: imports = [../../nvidia.nix];.

I only own one nvidia machine currently, though, I’m sure in the future I’ll find some ways in which this is not yet good enough and that will force me to refactor some of it. Or maybe not, I’ll probably avoid buying nvidia in the future.

Other things like my wireguard config are shared across all machines, but annoyingly verbose, so I place them in a global file where they don’t disrupt the general flow of configuration.nix.

That said, my configuration.nix still contains a lot of stuff, at least for my personal devices. I’ve not felt much of a need to overcomplicate it - in fact, the stuff I did with my home-manager configuration is painfully overcomplicated and I will probably rewrite that from scratch at some point (and make it much more plain).

4 Likes