The NixOS Module System Architecture

Updated

September 12, 2026

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:

  1. Loads every imported module (your files + nixpkgs NixOS modules).
  2. Merges options (cannot define the same option twice).
  3. Merges config with priorities.
  4. 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.port

Output:

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-vm

Output (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.port

On a flake host:

nix eval .#nixosConfigurations.desk-vm.options.services.deskOperations.port.description

man 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.nix files.
  • mkIf cfg.enable around every implementation.
  • mkDefault in shared profiles. mkForce only when a conflict is the point.
  • Strict types (bool, port, str, listOf str, submodule). Never anything for desk options.
  • assertions for invariants (port ≠ 22). They fail at eval, not at 3 a.m.
  • mkMerge, not //, when composing config. mkBefore/mkAfter for list order.
  • Inspect with nixos-option / nix eval, not by reading /etc after the fact.

Try this

  1. Add workers = lib.mkOption { type = lib.types.ints.positive; default = 4; }; and print it from desk-banner.
  2. Set port = 70000 and read the type error.
  3. Put networking.hostName = lib.mkDefault "a"; in one file and networking.hostName = "b"; in another. Eval. Then change the second to mkDefault "b" and see the conflict.
  4. 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.