The NixOS Module System Architecture
The NixOS Module System Architecture
Editing /etc by hand and hoping two files agree is how machines drift. The boring default is: the NixOS module system — typed options, merged config, and one evaluation that becomes a generation.
You already installed a VM. This chapter is how that configuration.nix is not a special snowflake: it is one module among many, including the ones you will write for the desk.
Mental model
A module is a function:
{ config, lib, pkgs, ... }:
{
options = { ... }; # what *may* be set (types, defaults, docs)
config = { ... }; # what *is* set (units, packages, files)
imports = [ ... ]; # other modules, merged in
}If you omit options and only set config keys (services.openssh.enable = true;), you are still a module — you are consuming options someone else declared (usually in nixpkgs).
The evaluator:
- Loads every imported module (your files + nixpkgs NixOS modules).
- Merges
options(cannot define the same option twice). - Merges
configwith priorities. - Type-checks. Failures are eval errors, not “nginx failed at 3 a.m.”
| Helper | Role |
|---|---|
lib.mkEnableOption "…" |
Boolean enable with a description |
lib.mkOption { type, default, … } |
Any typed option |
lib.mkIf cond cfg |
Include cfg only when cond is true |
lib.mkDefault value |
Low priority (easy to override) |
lib.mkForce value |
High priority (wins over ordinary assignment) |
lib.mkMerge [ a b ] |
Merge two config attrsets |
desk-module.nix (options + mkIf)
host.nix (enable = true; port = 9090)
nixpkgs modules (openssh, users, …)
│
▼
one config → one system derivation
Worked examples
Case 1: A small desk module
Save as desk_module.nix:
# desk_module.nix
{ lib, config, pkgs, ... }:
let
cfg = config.services.deskOperations;
in
{
options.services.deskOperations = {
enable = lib.mkEnableOption "Desk operations banner";
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "TCP port printed by the banner.";
};
banner = lib.mkOption {
type = lib.types.str;
default = "Desk Operational";
description = "Text printed by desk-banner.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.port != 22;
message = "deskOperations.port must not steal sshd's 22";
}
];
environment.systemPackages = [
(pkgs.writeShellScriptBin "desk-banner" ''
echo "${cfg.banner} on port ${toString cfg.port}"
'')
];
};
}cfg.enable is false by default. Nothing is installed until a host turns it on. That is the whole point of mkIf.
Case 2: Consume it from a host
Save as host.nix:
# host.nix
{
imports = [ ./desk_module.nix ];
services.deskOperations = {
enable = true;
port = 9090;
banner = "Window A online";
};
}On a NixOS flake (or a lab nixosSystem eval):
nix eval .#nixosConfigurations.desk-vm.config.services.deskOperations.portOutput:
9090
The option is an integer (lib.types.port), not a string. Passing "9090" is an eval error.
Case 3: Priorities — mkDefault versus mkForce
Save as priorities.nix:
# priorities.nix
{ lib, ... }:
{
networking.hostName = lib.mkDefault "generic-desk";
services.openssh.enable = lib.mkForce true;
}Save as host-override.nix:
# host-override.nix
{
imports = [ ./priorities.nix ];
networking.hostName = "desk-vm";
}Ordinary assignment ("desk-vm") beats mkDefault. It does not beat mkForce. If a later file sets services.openssh.enable = false;, evaluation fails (conflict) unless that file also uses mkForce.
Use mkDefault in shared profiles (corp baseline). Use mkForce rarely: “this host must have sshd,” not as a habit.
// on two config attrsets replaces nested keys. lib.mkMerge [ a b ] merges them the module way (lists concatenate, priorities apply). Shared modules that config = lib.mkMerge [ … ] compose; config = parent // { … } is how you accidentally drop wantedBy.
lib.mkBefore / lib.mkAfter order list items (environment.etc lines, systemd drop-ins). Reach for them when order is the bug, not when you meant mkForce.
Case 4: Types catch mistakes at eval
Save as bad-port.nix:
# bad-port.nix
{
imports = [ ./desk_module.nix ];
services.deskOperations = {
enable = true;
port = "ninety";
};
}nixos-rebuild dry-build --flake .#desk-vmOutput (shape):
error: A definition for option `services.deskOperations.port' is not of type `16 bit unsigned integer; between 0 and 65535 (inclusive)'.
No unit started. No half-applied /etc. That is the module system paying rent.
Case 5: Inspect options without guessing
nixos-option services.openssh.enable
nixos-option services.deskOperations.portOn a flake host:
nix eval .#nixosConfigurations.desk-vm.options.services.deskOperations.port.descriptionman configuration.nix (or nixos-help) is the same data as HTML. Prefer nixos-option / nix eval when you want the live merged value, not the upstream default.
The trap
The trap is type = lib.types.anything (or no type) so “it just merges.” Then port = "8080"; ships, the script interpolates a string, and the unit fails at boot. Types are the boring check.
The other trap is defining options.services.openssh.enable yourself. That option already exists. You set it; you do not redeclare it. Redeclare → “option already defined.”
A third trap: putting implementation in options (an option whose default starts a service). Defaults should be data. Side effects belong in config = mkIf cfg.enable { … }.
The boring rule
- One concern per module. Import it from the host. Do not paste the same 80 lines into three
configuration.nixfiles. mkIf cfg.enablearound every implementation.mkDefaultin shared profiles.mkForceonly when a conflict is the point.- Strict types (
bool,port,str,listOf str,submodule). Neveranythingfor desk options. assertionsfor invariants (port ≠ 22). They fail at eval, not at 3 a.m.mkMerge, not//, when composing config.mkBefore/mkAfterfor list order.- Inspect with
nixos-option/nix eval, not by reading/etcafter the fact.
Try this
- Add
workers = lib.mkOption { type = lib.types.ints.positive; default = 4; };and print it fromdesk-banner. - Set
port = 70000and read the type error. - Put
networking.hostName = lib.mkDefault "a";in one file andnetworking.hostName = "b";in another. Eval. Then change the second tomkDefault "b"and see the conflict. nixos-option services.openssh.settings.PasswordAuthentication(or eval the flake option) and write down the default. Confirm it matches what you intend to set in the SSH chapter.