5.9 KiB
NixOS Modules
This note covers 04-nixos-module/, which defines the smallest useful NixOS module: one option, one effect, exposed as a flake output other flakes
can import.
1. What a Module Is
A NixOS module is a function from module arguments ({ lib, config, pkgs, ... }) to an attrset with two sections:
options: declarations of typed configuration points.config: values assigned to options declared by this module, and by other modules in the set.
The NixOS module system collects every module, merges their options declarations, merges their config assignments, resolves the merged config
against the merged options, and exposes the final result as a single config value. Your system configuration is just another module in the set.
A module can also appear as a plain attrset when it has no arguments. The function form is the general case.
2. The Module in 04-nixos-module/
module.nix declares one option namespace, playground.greeter, with two options:
enable: a boolean toggle, produced bylib.mkEnableOption.name: a string with default"world", produced bylib.mkOption.
When enable is true, the config block defines environment.etc."greeting".text to "hello, ${cfg.name}". lib.mkIf cfg.enable { ... } wraps the
block so it contributes nothing when disabled, rather than setting options to null or empty values.
The module has no imports, no pkgs usage, and no services.* interaction. That keeps the example focused on declaring an option and conditionally
assigning one piece of config.
3. Exposing the Module From a Flake
A flake ships modules through the nixosModules output:
outputs = { self, nixpkgs, ... }: {
nixosModules.default = import ./module.nix;
};
Downstream flakes consume it like any other module:
{
inputs.playground.url = "github:you/nix-playground?dir=04-nixos-module";
outputs = { self, nixpkgs, playground }: {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
playground.nixosModules.default
./configuration.nix
];
};
};
}
nixosModules.<name> is the conventional path; nothing in the module system enforces the name, but tools and users expect it.
4. Verifying a Module Without Building a System
nix flake check needs the module to be exercised somehow. The example uses the smallest viable approach: evaluate a throwaway NixOS configuration
that imports the module, set the options to known values, and read one attribute back out.
testConfig = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
self.nixosModules.default
{
system.stateVersion = "24.11";
playground.greeter = { enable = true; name = "flakes"; };
}
];
};
checks.${system}.greeting = pkgs.runCommand "greeting-check" {
got = testConfig.config.environment.etc."greeting".text;
expected = "hello, flakes";
} ''
[ "$got" = "$expected" ] || { echo "got=$got"; exit 1; }
echo ok > $out
'';
Three things to notice:
- The test config never triggers
system.build.toplevel, so assertions that normally demandfileSystems."/", a bootloader, and a hostname do not fire. Reading a single value through.config.…is a pure option lookup. pkgs.runCommandreceives the computed greeting as an environment variablegot. The comparison happens at build time, inside the sandbox, so a mismatch fails the build.- The check only proves evaluation and one config value. Richer modules deserve richer checks: multiple configurations, negative cases, or a full
pkgs.nixosTestVM.
5. Option Types and Merging
lib.types covers the common cases:
types.bool,types.int,types.str,types.path,types.package.types.listOf <t>,types.attrsOf <t>,types.nullOr <t>.types.enum [ "a" "b" ],types.submodule { options = { ... }; }.types.lines,types.separatedString <sep>for accumulating strings.
Each type defines how multiple assignments merge. Booleans OR together by default, strings with types.lines concatenate with newlines, lists with
types.listOf concatenate, attrsets with types.attrsOf deep-merge. When you need to override the merge, use lib.mkForce, lib.mkDefault,
lib.mkBefore, or lib.mkAfter.
This merge behavior is why multiple modules can each add packages to environment.systemPackages without stepping on each other: the list type
accumulates.
6. Commands to Try
cd 04-nixos-module
nix flake show # confirms nixosModules.default and checks.*.greeting
nix flake check # evaluates the module and builds the check
# Inspect the module's effect on the test configuration:
nix eval .#checks.x86_64-linux.greeting.drvAttrs.got
# Flip the option off in a scratch eval to confirm mkIf elides the config:
nix eval --impure --expr '
let
flake = builtins.getFlake (toString ./.);
sys = (import <nixpkgs> {}).lib.nixosSystem {
system = "x86_64-linux";
modules = [
flake.nixosModules.default
{ system.stateVersion = "24.11"; playground.greeter.enable = false; }
];
};
in sys.config.environment.etc ? greeting
'
The last command should print false: with enable = false, the mkIf block contributes nothing, so environment.etc.greeting is never declared.
7. When to Reach for a Module
Modules earn their weight in a few recurring situations:
- The same configuration needs to turn on across several hosts or flakes.
- Consumers deserve typed, documented knobs instead of raw config snippets.
- The integration belongs alongside
services.*,environment.*,users.*, orsystemd.*rather than next to them. - A change set benefits from option merging, so multiple modules can contribute without overwriting each other.
For a one-off tweak to a single host, a plain configuration.nix snippet is usually smaller. The module system pays off once the same configuration
needs reuse, parameterization, or validation.