Your configuration.nix starts out as one tidy little file. Then it grows users,
a desktop, hardware quirks, and the handful of settings that only make sense on
this one machine, until you open it one afternoon and it is nine hundred lines
long.
Modules let each file own one understandable piece. NixOS evaluates all of them together and produces one final configuration.
That last part is the bit worth slowing down for, because it is not what “splitting a file up” usually means.
The configuration you already know is a valid module:
A module is not defined by its filename or its path. It is just a Nix value the module system can evaluate and merge.
And this is the shape you’ll actually meet most of the time. A module asks NixOS
for the values it needs, usually pkgs, lib, or the final config, and hands
back an attribute set:
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.git ];
}
The function shape supplies context. The returned attribute set is the module’s contribution.
The module below is still the plain set from the last step, and it needs a package. Give it the same header and install Vim with it.
Install Vim, which means asking NixOS for pkgs first.
- A module that uses
pkgshas to name it, the same way the example above does
Write pkgs.vim before the header is there and the editor underlines pkgs.
Nothing is in scope until the module actually asks for it.
configuration.nix usually acts as the entry point, and imports is a plain
list of the files that should join it:
Bring desktop.nix into the configuration alongside the other two.
- Add
./desktop.nixto the list
An imported file doesn’t run before or after this one. Import order doesn’t choose a winner. All imported modules join the same evaluation and contribute definitions to the same configuration.
Here are two modules that have never heard of each other. configuration.nix
imports them both. Take either line out of imports and watch what leaves the
result.
Every file is a real editor, and the configuration underneath is the one the module system would build from them. Add a package, set an option you have met before, or add a third module of your own.
merged system configuration
Nothing is imported, so there is nothing to build.
Both modules set the same option, and neither one lost. The packages were joined.
That joining was not a property of imports. It was a property of
environment.systemPackages, which holds a list.
For this list-typed option, ordinary definitions are joined. This is the whole reason a module can add a package without knowing which other modules exist, and it is why NixOS configurations compose at all.
The exact rule always belongs to the option’s declared type, and priorities like
mkForce can still change which definitions take part.
networking.hostName is a single string. A machine cannot have two names.
Switch on both host modules and read what the module system says.
merged system configuration
Every module is switched off, so there is nothing to build.
It didn’t pick one. It didn’t take the last file, or the first, or the one higher up the import list. It refused, and told you both places that made a claim.
This is worth appreciating rather than working around. Two modules disagreeing about a hostname is a real disagreement in your configuration, and silently resolving it would hide a bug you would meet later, on a running machine, wondering why the name is wrong.
The error names the option and every file that defined it. That is usually enough to know which one should not have.
When the disagreement is deliberate, lib.mkForce says so out loud.
Switch on the kiosk module so the forced value settles the argument.
merged system configuration
Every module is switched off, so there is nothing to build.
lib.mkDefault is the same idea pointing the other way. It marks a value as
“use this unless anybody else has an opinion”, which is how a shared profile can
offer a setting without imposing it.
Switch on the host module and watch the profile's suggestion give way.
merged system configuration
Every module is switched off, so there is nothing to build.
Notice what did not happen. Two files named the same single-value option and nothing complained, because a default is a weaker claim than an ordinary definition and gives way to one without an argument. You only get a refusal when both sides meant it equally.
So the ladder has three rungs. mkForce beats an ordinary definition, and an
ordinary definition beats mkDefault. Beside all three sits lib.mkMerge, which
hands the merge several definitions at once and lets the option’s own type join
them.
Now for the piece that makes modules genuinely powerful. The config argument
holds the final option values, after every module’s contribution has already been
merged in.
Here is firewall.nix, with the decision left blank. It should open port 22 only
when SSH is actually switched on, and the answer to that is already sitting in
config.
Swap the `false` for the question that decides it.
configholds the merged answer toservices.openssh.enable
This works because Nix evaluates lazily. A value may refer to another part of the configuration, as long as it does not end up depending on itself.
Read it in two passes: all modules first contribute their definitions, then the
module system works out the final services.openssh.enable. This firewall
definition reads that finished answer. It doesn’t reach into whichever file
happened to mention SSH.
Good module boundaries all answer one simple question: what idea does this file own? A user module owns accounts, a desktop module owns graphical programs, a service module owns one daemon.
What you want to avoid is splitting by length. Five files named part1.nix
through part5.nix are still one tangled idea wearing five coats.
- A module can be an attribute set or a function returning one.
importsadds modules to the same evaluation. Import order doesn’t choose winners.- The option’s type decides how definitions merge, not the file they came from.
- List options commonly join ordinary definitions, which is what makes modules composable.
- Single-value options refuse to guess and name the files that disagreed.
mkForceoutranks an ordinary definition, which outranksmkDefault, so only two equal claims are a conflict.mkMergeoffers several at once.configholds the merged result, and any module may read it.


Share your thoughts