--- title: My custom lib.nixosSystem description: How I came to write my own lib.nixosSystem date: 2025-02-07 updated: 2026-04-20 tags: - learning - nix image: custom-lib-nixossystem.png atUri: "at://did:plc:qxichs7jsycphrsmbujwqbfb/site.standard.document/3mn5hpcabyt22" --- ## Introduction I've been using NixOS for a while now, and my biggest issue was that I have a lot of machines. Which leads to having a lot of different [systems](https://github.com/isabelroses/dotfiles/tree/main/systems) on my [flake](https://github.com/isabelroses/dotfiles) because of that hardware. The normal solution would be to use the module system, but a new issue arises when we add [nix-darwin](https://github.com/LnL7/nix-darwin). We suddenly start to fail eval over issues because some modules don't exist that are in the normal NixOS module system. So my new issue is that I can no longer use my small abstraction over [`lib.nixosSystem`](https://github.com/NixOS/nixpkgs/blob/5223d4097bb2c9e89d133f61f898df611d5ea3ca/flake.nix#L57-L79). I would have to expand the abstraction to include [`lib.darwinSystem`](https://github.com/LnL7/nix-darwin/blob/87131f51f8256952d1a306b5521cedc2dc61aa08/flake.nix#L21-L51) since I can no longer unconditionally import all modules I use if they don't exist in nix-darwin? But what if I don't want to do that? What if I want to write my own `lib.nixosSystem` or my own `lib.darwinSystem`? What if I call it [`mkSystem`](https://github.com/isabelroses/dotfiles/blob/24588b2101c0fb2a8587df377a670e9e4cc47b42/parts/lib/builders.nix#L17). Well that's exactly what I did. And this article covers how I got to that point and how my custom “builder” later evolved into [easy-hosts](https://github.com/tgirlcloud/easy-hosts). ## The research To start we should read the [documentation for `lib.evalModules`](https://nixos.org/manual/nixpkgs/stable/index.html#module-system-lib-evalModules). From which we find that there are 4 arguments, but 3 that we really care about. Those being `modules`, `specialArgs` and `class`. The `modules` argument is a list of modules, which are files, functions or attrsets that will be merged and then evaluated. The `specialArgs` argument is a attrset of arguments that are passed to the modules, but are not evaluated with the file structure in mind. The `class` argument is a nominal type that ensures that only compatible modules are imported. This is a very important argument, as it allows us to have different modules for different systems. However, I'm not much a fan of reading documentation. So instead lets dig into the source code and pick it apart. To find out what we need to get our custom `lib.nixosSystem` working. To do this I identified 4 main files in the nixpkgs repository: - [flake.nix](https://github.com/NixOS/nixpkgs/blob/5223d4097bb2c9e89d133f61f898df611d5ea3ca/flake.nix) - [nixos/lib/eval-config.nix](https://github.com/NixOS/nixpkgs/blob/5223d4097bb2c9e89d133f61f898df611d5ea3ca/nixos/lib/eval-config.nix) - [nixos/lib/eval-config-minimal.nix](https://github.com/NixOS/nixpkgs/blob/5223d4097bb2c9e89d133f61f898df611d5ea3ca/nixos/lib/eval-config-minimal.nix) - [lib/modules.nix](https://github.com/NixOS/nixpkgs/blob/5223d4097bb2c9e89d133f61f898df611d5ea3ca/lib/modules.nix) These files may seem somewhat arbitrary, but they are listed in the order they are called. The `flake.nix` file has our `lib.nixosSystem` function that calls our `nixos/lib/eval-config.nix` file which is a light wrapper around our final `lib/modules.nix`. So lets walk through those files in order and see what exactly they do. ### `flake.nix` This file contains our `lib.nixosSystem` function, which takes `args` as an argument. The [documentation](https://github.com/NixOS/nixpkgs/blob/b78b5ce3b29c147e193633659a0ea9bf97f6c0c0/flake.nix#L44) lists a set of known arguments being `modules`, `specialArgs` and `modulesLocation`, it also specifies some additional legacy arguments `system` and `pkgs` (both of which are now redundant). The `lib.nixosSystem` then imports the `nixos/lib/eval-config.nix` file whilst passing `lib`, and the remaining `args` to it. It also sets `system` to `null` as well as adding `nixpkgs.flake.source` to nixpkgs output derivation. ### nixos/lib/eval-config.nix This file immediately points us to the fact that it is a “light wrapper” around `lib.evalModules`. This file also has a large collection of arguments most of which will be the defaults. A good example of this is `baseModules` which defaults to a list of modules from the nixpkgs repo. The most important arguments from this file are `specialArgs`, `lib` and `modules`. For the most part these come from the prior `flake.nix` file. As we read down the file we notice that there are two additional modules that are going to be added. These are the `pkgsModule` and the `modulesModule`. These appear to be pretty strange names at first, but the `pkgsModule` will set `nixpkgs.system` if `system` was not null, and will set `nixpkgs.pkgs` if `pkgs` is not null. The `modulesModule` will add `config._module.args` as an attrset of `noUserModules`, `baseModules`, `extraModules` and `modules`. So now we know some of the arguments that are given to `lib.evalModules` lets see what that does. ### nixos/lib/eval-config-minimal.nix This file is a small wrapper upon `lib.evalModules`, but it gives us a little bit of guidance on how to use the `class` argument. As well as showing us the default for `modulePath` which is going to be passed as a special arg. ### lib/modules.nix This file won't add much to our understanding of `lib.evalModules`, but it still is the definition of the function, so it's good to keep in mind if we have any issues later down the line. ## The implementation Now that we have our key inputs of `class`, `modules` and `specialArgs` we can start implementing our own `lib.nixosSystem`. ### Getting the basics Let us start in our very own `flake.nix` by writing the following code. This will give us a basic template to work with and you, the reader, knows how to start. ```nix { inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = inputs: let mkSystem = import ./mksystem.nix { inherit inputs; }; in { nixosConfigurations = { mySystem = mkSystem { }; }; }; } ``` Now that we have a very bare bone `flake.nix` we can get started on the `mkSystem` function. Let's also create the `mksystem.nix` file. We are going to add some basic args that we know we are going to need later such as `modules`, `specialArgs` and `class`. And we are going to add some defaults to these args such that we don't get any errors if they are not provided. ```nix { inputs ? throw "No inputs provided", lib ? inputs.nixpkgs.lib, ... }: { modules ? [ ], specialArgs ? { }, class ? "nixos", }: lib.evalModules { inherit modules specialArgs class; } ``` ### Adding `modulesPath` Now that we have our basic template down. Let's start by adding the `modulesPath`, most people probably recognize this from when they first installed nix and read their `hardware-configuration.nix` file and saw something along the lines of `modulesPath + /installer/scan/not-detected.nix`. That is why we are starting with this, so that our `hardware-configuration.nix` will work. The key to this is going to be including `modulesPath` in our `specialArgs`. This is because `specialArgs` should only be used with arguments that need to be evaluated when resolving module structure. ```nix { inputs, lib ? inputs.nixpkgs.lib, ... }: { modules ? [ ], specialArgs ? { }, class ? "nixos", }: let modulesPath = "${inputs.nixpkgs}/nixos/modules"; in lib.evalModules { inherit modules class; # here we are merging the user provided specialArgs with the modulesPath specialArgs = { inherit modulesPath; } // specialArgs; } ``` ### So close and yet so far Only adding `modulePath` is a bit useless however, so we can't exactly replace our `lib.nixosSystem`'s yet. So let's work on that. We are going to start by importing `baseModules`, this will provide us with a base set of modules that provide abstractions over configuring your system. We can use the `modulePath` and get the module list. ```nix { inputs, lib ? inputs.nixpkgs.lib, ... }: { modules ? [ ], specialArgs ? { }, class ? "nixos", }: let modulesPath = "${inputs.nixpkgs}/nixos/modules"; baseModules = import "${modulesPath}/module-list.nix"; in lib.evalModules { inherit class; specialArgs = { inherit modulesPath; } // specialArgs; modules = baseModules ++ modules; } ``` ### It actually works? We now have a _mostly_ functional replacement. Depending on your configuration may actually work as it is now! To keep progressing we are going to have to go back to the `modulesModule` from [earlier](#nixos/lib/eval-config.nix). we need this such that some nixpkgs modules will work, one of these is the [documentation module](https://github.com/NixOS/nixpkgs/blob/48f79c1d5168ce8e9b21a790be523c9a8f60046c/nixos/modules/misc/documentation.nix#L1) which will be a hard module to ignore, when so many people use it. To fix this we are going to introduce a new module which contains `config._module.args` and takes a set of attrs that will be passed to each module. I'm sure most of you recognize these when writing a module and adding something like `{ pkgs, config, ... }` to the top of a file. The `config._module.args` option should be used when trying to pass arguments to all modules, but should not be evaluated with file structure in mind. ```nix { inputs, lib ? inputs.nixpkgs.lib, ... }: { modules ? [ ], specialArgs ? { }, class ? "nixos", }: let modulesPath = "${inputs.nixpkgs}/nixos/modules"; baseModules = import "${modulesPath}/module-list.nix"; in lib.evalModules { inherit class; specialArgs = { inherit modulesPath; } // specialArgs; modules = baseModules ++ modules ++ [ { config._module.args = { inherit baseModules modules; }; } ]; } ``` ### Adding some of our own modules Even better, now we have completely replaced `lib.nixosSystem` with our own `mkSystem` function. But let's be real. That's not enough for us. We should start abstracting some common themes between our systems. Some big examples of this are `networking.hostName` and `nixpkgs.hostPlatform`. And while were at it lets also re-add the `nixpkgs.flake.source` from the original `lib.nixoSystem`, as well as adding `inputs` as a special arg. As most people do this anyway, I think it's a safe assumption we should add it. For further reading about passing inputs to modules check [nobbz's blog on getting inputs to flake modules](https://blog.nobbz.dev/2022-12-12-getting-inputs-to-modules-in-a-flake/). ```nix { inputs, lib ? inputs.nixpkgs.lib, ... }: name: { modules ? [ ], specialArgs ? { }, class ? "nixos", }: let modulesPath = "${inputs.nixpkgs}/nixos/modules"; baseModules = import "${modulesPath}/module-list.nix"; in lib.evalModules { inherit class; specialArgs = { inherit inputs modulesPath; } // specialArgs; modules = baseModules ++ modules ++ [ { config. = { _module.args = { inherit baseModules modules; }; networking.hostName = name; nixpkgs.flake.source = inputs.nixpkgs.outPath; }; } ]; } ``` I just added `name` as an additional argument to our `mkSystem` function. This allows us to set the hostname of our system. The way I opted to write it allows for us to use `mapAttrs` on our `nixosConfigurations`. This will mean that we need to change how the original `flake.nix` works though. ```nix { inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = inputs: let mkSystem = import ./mksystem.nix { inherit inputs; }; in { nixosConfigurations = builtins.mapAttrs mkSystem { mySystem = { }; }; }; } ``` Furthermore, notice how I lied about settings `nixpkgs.hostPlatform`. If your curious why, maybe you should read [my last blog post about it](https://isabelroses.com/blog/im-not-mad-im-disappointed). (Shameless plug) ### Darwin compatibility One of the main reasons I wanted to make this was to support `lib.darwinSystem`. So let's address that now. To introduce Darwin support we are allowing users to set the `class` argument to `darwin` from there we can determine what modules to import. As a result of this you may notice that Darwin has a different set of modules which introduced some new options to set for this system type. This includes `nixpkgs.source` and `darwinVersionSuffix` and `darwinRevision`. Some of these are for commands like `darwin-version`. You may also notice that we had to add `system = eval.config.system.build.toplevel` back into the final eval produced by our Darwin eval. This is required to swap to the configuration, otherwise it won't work at all. This is because the `system` output is used by `darwin-rebuild`, to identify the final build output. ```nix { inputs, lib ? inputs.nixpkgs.lib, ... }: name: { modules ? [ ], specialArgs ? { }, class ? "nixos", }: let # this is new? what is it? # I'm glad you asked, this is a nice way of checking if we have our darwin and nixpkgs inputs nixpkgs = inputs.nixpkgs or (throw "No nixpkgs input found"); darwin = inputs.darwin or inputs.nix-darwin or (throw "No nix-darwin input found"); modulesPath = if class == "darwin" then "${darwin}/modules" else "${nixpkgs}/nixos/modules"; baseModules = import "${modulesPath}/module-list.nix"; eval = lib.evalModules { inherit class; specialArgs = { inherit inputs modulesPath; } // specialArgs; modules = baseModules ++ modules ++ [ { config. = { _module.args = { inherit baseModules modules; }; networking.hostName = name; nixpkgs.flake.source = nixpkgs.outPath; }; } ] ++ lib.optionals (class == "darwin") [ { config = { nixpkgs.source = nixpkgs.outPath; system = { checks.verifyNixPath = false; darwinVersionSuffix = ".${darwin.shortRev or darwin.dirtyShortRev or "dirty"}"; darwinRevision = darwin.rev or darwin.dirtyRev or "dirty"; }; }; } ]; }; in if class == "darwin" then (eval // { system = eval.config.system.build.toplevel; }) else eval; ``` ### The final touch The final and maybe the best bit is adding `inputs'`. For those who are unaware of flake-parts, you probably are not aware of the greatness that is `inputs'`. The diff below shows the advantage of using `inputs'` over `inputs` for accessing packages. ```diff - inputs.input-name.packages.${pkgs.stdenv.hostPlatform.system}.package-name + inputs'.input-name.packages.package-name ``` Is that not awesome? So how can we replicate that for ourselves? What we will need to do is map over all inputs, and their outputs and select the output dependent on the host platform, if a system dependent output exists, otherwise it will leave it as is. We can achieve that with the following code: ```nix inputs' = lib.mapAttrs (_: lib.mapAttrs (_: v: v.${config.nixpkgs.hostPlatform.system} or v)) inputs; ``` Or if you are using flake-parts, you may prefer using the following code instead: ```nix withSystem config.nixpkgs.hostPlatform ({ inputs', ... }: { inherit inputs'; }); ``` So let's add that to our `mkSystem` function. ```nix { inputs, lib ? inputs.nixpkgs.lib, ... }: name: { modules ? [ ], specialArgs ? { }, class ? "nixos", }: let nixpkgs = inputs.nixpkgs or (throw "No nixpkgs input found"); darwin = inputs.darwin or inputs.nix-darwin or (throw "No nix-darwin input found"); modulesPath = if class == "darwin" then "${darwin}/modules" else "${nixpkgs}/nixos/modules"; baseModules = import "${modulesPath}/module-list.nix"; eval = lib.evalModules { inherit class; specialArgs = { inherit inputs modulesPath; } // specialArgs; modules = baseModules ++ modules ++ [ ({ config, ... }: { config = { _module.args = { inherit baseModules modules; inputs' = lib.mapAttrs (_: lib.mapAttrs (_: v: v.${config.nixpkgs.hostPlatform.system} or v)) inputs; }; networking.hostName = name; nixpkgs.flake.source = nixpkgs.outPath; }; }) ] ++ lib.optionals (class == "darwin") [ { config = { nixpkgs.source = nixpkgs.outPath; system = { checks.verifyNixPath = false; darwinVersionSuffix = ".${darwin.shortRev or darwin.dirtyShortRev or "dirty"}"; darwinRevision = darwin.rev or darwin.dirtyRev or "dirty"; }; }; } ]; }; in if class == "darwin" then (eval // { system = eval.config.system.build.toplevel; }) else eval; ``` ## Conclusion And that's it! We have a fully functional `mkSystem` function that can replace both `lib.nixosSystem` and `lib.darwinSystem`. This was quite the task, and although this blog post seems to reduce the quite simple. I've spent a lot of time on this, both when researching how to create the custom builder and writing and maintaining the latest rendition in the form of a flake module called [easy-hosts](https://github.com/tgirlcloud/easy-hosts). If you enjoyed this post, please consider donating on [ko-fi](https://ko-fi.com/isabelroses) or [github sponsors](https://github.com/sponsors/isabelroses).