Methods of Making Your NixOS Config More Modular

Associated Youtube Video: https://youtu.be/bV3hfalcSKs

Associated Odysee Video: https://odysee.com/@LibrePhoenix:8/how-to-start-adding-modularity-to-your:1

Previous Tutorial: How to Manage Your Dotfiles the Nix Way with Home Manager

Next Tutorial: NixOS Conditional Config and Custom Options

This is a discussion of how to make your NixOS configuration more modular. The main ideas is that imports allow you to source multiple Nix files (we call them modules) into your configuration.nix or home.nix.

Tools Needed

Adding More Nix Modules

The first and easiest way is to simply create more Nix modules. A Nix module is usually a separate Nix file, but it technically doesn’t need to be.

In configuration.nix or home.nix you can add a list of imports like so:

imports = [ ./import1.nix
            ./import2.nix
            ...
          ];

My home.nix file itself has imports that are essentially like this (Disclaimer: If you actually go and look at my config, you’ll notice that the imports are a bit more complex then what you see above, but I simplified it to make it more understandable):

  imports = [
              ../../user/wm/hyprland/hyprland.nix # My wm
              # ../../user/wm/xmonad/xmonad.nix # My wm
              ../../user/shell/sh.nix # My zsh and bash config
              ../../user/shell/cli-collection.nix # Useful CLI apps
              ../../user/bin/phoenix.nix # My nix command wrapper
              ../../user/app/doom-emacs/doom.nix # My doom emacs config
              ../../user/app/ranger/ranger.nix # My ranger file manager config
              ../../user/app/git/git.nix # My git config
              ../../user/app/keepass/keepass.nix # My password manager
              ../../user/app/browser/qutebrowser.nix # Qutebrowser
              # ../../user/app/browser/brave.nix # Brave Browser
              # ../../user/app/browser/librewolf.nix # Brave Browser
              ../../user/app/virtualization/virtualization.nix # Virtual machines
              ../../user/app/flatpak/flatpak.nix # Flatpaks
              ../../user/style/stylix.nix # Styling and themes for my apps
              ../../user/lang/cc/cc.nix # C and C++ tools
              ../../user/lang/godot/godot.nix # Game development
              ../../user/pkgs/blockbench.nix # Blockbench
              ../../user/hardware/bluetooth.nix # Bluetooth
            ];

As you can see, the extra files are imported via a relative path, but I have left some of them commented out. The commented ones are configurations that conflict, like my xmonad and hyprland config, which conflict because hyprland.nix will install wayland versions of things that conflict with xorg packages that I want in my xmonad.nix. So, you could imagine that once this is set up, all you would need to do is comment and uncomment specific modules in the imports in order to turn them on or off.

So, how do I write a Nix module?

First we need to understand functions

A Nix module for NixOS or home-manager is technically defined as: a function which accepts an attribute set of config and pkgs as an input (at the very least) and returns an attribute set of configuration settings. What does this mean?

First, a reminder on how functions in Nix are defined. A Nix function only accepts a single argument (argument in this case) and this is followed by a colon (:) and then theThingBeingReturned.

argument: theThingBeingReturned

If we wanted to write a function that takes an integer x as an input and returns that number plus 1, we would write:

x: x + 1

If we want to write a function that takes multiple inputs, we could either nest functions inside of each other (called curried functions):

x: y: x + y

or, we can make the argument to the function an attribute set:

{x, y}: x + y

All functions in Nix are considered anonymous meaning they have no names (also called a lambda). You can, however, store this in a name (or variable of sorts) using a let binding like this:

let
  addNumbers = {x, y}: x + y;
in addNumbers {x = 2; y = 2;}
=> 4

You can give a default value to any argument by using the ? operator, like so:

let
  addNumbers = {x, y ? 4}: x + y;
in addNumbers {x = 2; y = 2;}
=> 4

In the above case, since y is supplied to the function, it uses that, so the function runs 2 + 2 and returns 4.

If we instead, however, do not supply the y argument to the function, it still runs by using the default value:

let
  addNumbers = {x, y ? 4}: x + y;
in addNumbers {x = 2;}
=> 6

Back to Nix Modules

So, a Nix module is a function which needs the argument (input) to be an attribute set including config and pkgs at least and, the return type must be an attribute set of configuration settings. That’s where we get this:

{ config, pkgs, ... }:
{
  # your options go here
  # this is an attribute set of configuration options
  # for example:
  # bash.enable = true;
}

Any arbitrary file can be setup like this and imported into configuration.nix or home.nix, like so:

# configuration.nix or home.nix
{ config, pkgs, ... }:
{
  imports = [
    ./bash.nix
    ./neovim.nix
    ./emacs.nix
    # etc..
  ];

  # the rest of your configuration
}

Like in my dotfiles (GitHub, GitLab, Codeberg), these modules can be organized into subdirectories, so maybe you’d instead want something like this:

# configuration.nix or home.nix
{ config, pkgs, ... }:
{
  imports = [
    ./shells/bash.nix
    ./shells/zsh.nix
    ./shells/fish.nix
    ./editors/neovim.nix
    ./editors/emacs.nix
    # etc..
  ];

  # the rest of your configuration
}

Keep in mind though, that a module doesn’t need to be imported as a Nix file. Consider this neat trick where you place the contents of an entire module file as an import in parentheses!

# configuration.nix or home.nix
{ config, pkgs, ... }:
{
  imports = [
    (
      { config, pkgs, ... }:
      {
        bash.enable = true;
        bash.shellAliases = {
          ll = "ls -l";
          .. = "cd ..";
        };
      }
    )
    ./shells/zsh.nix
    ./shells/fish.nix
    ./editors/neovim.nix
    ./editors/emacs.nix
    # etc..
  ];

  # the rest of your configuration
}

If you’re running a flake, you may even consider putting your configuration.nix or home.nix in the modules list this way ;)

{
  description = "My first flake!";

  inputs = {
    nixpkgs.url = "nixpkgs/nixos-23.05";
    home-manager.url = "github:nix-community/home-manager/release-23.05";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs = { self, nixpkgs, home-manager, ... }:
    let
      system = "x86_64-linux";
      lib = nixpkgs.lib;
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      nixosConfigurations = {
        YOURHOSTNAME = lib.nixosSystem {
          inherit system;
          modules = [
            (
              { config, pkgs, ... }:
              {
                # imagine copying the entirety of your
                # configuration.nix here
              }
            )
          ];
        }
      };
      homeConfigurations = {
        USERNAME = home-manager.lib.homeManagerConfiguration {
          inherit pkgs;
          modules = [
            (
              { config, pkgs, ... }:
              {
                # imagine copying the entirety of your
                # home.nix here
              }
            )
          ];
        }
      };
}

A Note about Git and “File Doesn’t Exist” Errors

As you’re making these new files, if you have your configuration in a git repository (which you should!), then when you rebuild the configuration, it may complain about files not existing. This may seem like an annoying error, because the files clearly exist! However, this is a feature, not a bug.

If Nix determines that your config is in a git repo and there are unstaged files, it treats them like they don’t exist, so that you are sure that everything being built is at least a file staged into the repo for the next commit. Nix will build unstaged changes on files already staged or committed into the repo, and will give a warning that the git repo is “dirty.”

In any case, make sure files are added:

git add path/to/your/module.nix

if you’re getting “file doesn’t exist” errors.

Priorities

When module files are imported, the configurations are merged. This means that configuration options must not conflict. If you have, for example:

# home.nix
{ config, pkgs, ... }:
{
  imports = [
    ./shells/zsh.nix
  ];

  zsh.enable = false; # don't make .zshrc from home-manager
}
# shells/zsh.nix
{ config, pkgs, ... }:
{
  zsh.enable = true; # make .zshrc from home-manager
}

this will result in a build error, because zsh.enable = false; directly conflicts with zsh.enable = true;

In this case, you can resolve the conflict by not writing potential conflicts in to begin with or by giving some declarations a higher priority. Lower numbers are given higher weight for the priority level, (so 0 is the highest priority and 1000 is a very low priority). Priorities are given by using the following functions:

  • lib.mkDefault - Makes the priority for that value 1000
  • lib.mkForce - Makes the priority for that value 50
  • lib.mkOverride - Makes the priority for that value an arbitrary number

To use these, we need to import lib. Here are a few examples using each:

# home.nix
{ config, lib, pkgs, ... }:
{
  fish.enable = lib.mkDefault false; # the value `false` gets a priority of 1000
}
# fish.nix
{ config, lib, pkgs, ... }:
{
  fish.enable = lib.mkForce true; # the value `true` gets a priority of 50
}

If the two examples above were both imported as modules, the second one (lib.mkForce true) would override the first (lib.mkDefault false) since it has a higher priority (50 is more important than 1000). As one last example:

# no-fish.nix
{ config, lib, pkgs, ... }:
{
  fish.enable = lib.mkOverride 20 false; # the value `false` gets a priority of 20
}

If all three of these were now included, the no-fish.nix module would override all the other ones, since it has a priority of 20, thus disabling generation of the fish configuration files by home-manager.

Auto-merging for Lists and Attribute Sets

Another cool feature of modules is that lists and attribute sets are actually merged, meaning that they get combined into one in the final configuration! This is really useful for adding extra packages within each module, like so:

# home.nix
{ config, pkgs, ... }:
{
  imports = [
    ./wm/hyprland.nix
    # ./wm/xmonad.nix
  ];

  home.packages = with pkgs; [
    wget
    curl
    vim
    brave
  ];
}
# wm/hyprland.nix
{ config, pkgs, ... }:
{
  home.packages = with pkgs; [
    fuzzel
    polkit_gnome
    wlr-randr
    wtype
    wl-clipboard
    hyprland-protocols
    hyprpicker
    swayidle
    gtklock
    swaybg
    xdg-desktop-portal-hyprland
    wlsunset
    pavucontrol
    pamixer
    grim
    slurp
  ];
}
# wm/xmonad.nix
{ config, pkgs, ... }:
{
  home.packages = with pkgs; [
    xmobar
    pamixer
    rofi
    flameshot
    feh
    alttab
    xdotool
    xclip
    sct
    xorg.xcursorthemes
    xorg.xev
    xdg-desktop-portal-gtk
  ];
}

With this, you can make sure that your only installing packages included within the relevant modules you’re loading.

Your Homework Assignment for This Lesson

Start by making a list of modules (modules meaning: “chunks of the system configuration I’d like to be able to turn on and off and/or mix and match”). If you want some inspiration, take a look at my dotfiles (GitHub, GitLab, Codeberg). Once you know what modules you want to make, utilize tools like MyNixOS to explore options and write the configuration options into your separate “module” Nix files. Then, you can finally add every module into your imports list of configuration.nix and home.nix.

Note: As you may have already figured out, if you installed home-manager using the standalone installation, Nix module files for the configuration.nix and home.nix must be separate.

Next time we will look at a method of extending the power of this modularity through if-else statements and custom options, which allow you to pass a kind of “global” variable from a flake.nix into all of your Nix modules!

Donation Links

If you have found my work to be helpful, please consider donating via one of the following links. Thank you very much!