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
.
Table of Contents
Tools Needed
- A system with the Nix Package Manager installed
- Home-manager installed with
home.nix
(optional) - Your configuration managed by a
flake.nix
(optional, but some parts of this tutorial will be irrelevant without it)
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 1000lib.mkForce
- Makes the priority for that value 50lib.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!