Developing NixOS (and Home Manager) Modules

If you use NixOS or Home Manager, chances are that you have (unknowingly) created a NixOS module. In this post, I'll document my learnings around how they work, and how to easily test them in isolation.

Also note that for the sake of my sanity, I'll only talk about NixOS in this post, but the same concepts also apply to HomeManager.

What are modules?

In short, modules are isolated components that make up NixOS.

If you have moved parts of your NixOS configuration to a separate file, and then included it again using imports = [ ... ];, then you have written a module!

Because that's what modules are: Nix expressions that import other modules, define options other modules can set, and that in turn set options in other modules.

The basics

The most basic module looks like this:

{
  services.openssh.enable = true;
}

But to show the full structure, a minimal example would actually look like this:

{ ... }:

{
  imports = [ ];

  options = { };

  config = {
    services.openssh.enable = true;
  };
}

Defining Options

In modules, we can define our own options:

{ lib, config, ... }:

let
  # Convention to access "our own" configuration
  cfg = config.mymodule;

in
{
  options = {
    mymodule = {
      firstName = lib.mkOption {
        description = "Your first name";
        type = lib.types.str;
        default = "John";
      };
      lastName = lib.mkOption {
        description = "Your last name";
        type = lib.types.str;
        default = "Doe";
      };
      fullName = lib.mkOption {
        type = lib.types.str;
      };
    };
  };

  config = {
    mymodule.fullName = "${cfg.firstName} ${cfg.lastName}";
  };
}

Once imported, other modules can now set mymodule.firstName, and it will be available to us as config.mymodule.firstName (or cfg.firstName thanks to the let binding).

Options should always have a type to prevent errors. See NixOS: Option Types for a full list of types. Note that HomeManager brings a couple of extra option types.

Testing modules

Now I love to iterate quickly on my code, so rebuilding my entire NixOS configuration is not practical. I also want to inspect the output of my modules without having to check what changes Nix actually applied to the system.

So let's create a small "NixOS module sandbox" in sandbox.nix:

(import <nixpkgs/lib>).evalModules {
  modules = [{
    # import your module here
    imports = [ ./mymodule.nix ];

    # For testing you can set any configs here
    mymodule.firstName = "Jaques";
  }];
}

If we put our example module from above in mymodule.nix, we can now evaluate everything:

nix-instantiate --eval ./sandbox.nix --strict -A config

Some notes on the options used

And then using a bit of entr and jq, we get:

ls -1 *.nix | \
entr sh -c 'nix-instantiate --eval ./sandbox.nix --strict -A config --json | jq'

Further reading


Next post: "Extractig Generic Substates in Axum"
Previous post: "NixOS on Hetzner Dedicated"
List all Blog posts