Module system in depth
Module system in depth
Goal: Use a real type catalog, compose with mkIf / mkMerge / priorities correctly, add assertions, and redesign one production-shaped module so bad configs fail at eval time.
You already consumed modules (NixOS host part) and wrote a custom one (custom modules chapter). This chapter is the types + merge pass—not another “enable nginx” tutorial.
Why this chapter exists
Untyped attrs options accept anything until activation or first request fails. Priority mistakes silently lose settings (mkDefault overridden by accident, or mkForce used as a hammer). Capstone hosts need modules that reject nonsense early and merge predictably across roles.
Theory 1 — Anatomy of a module (recall with precision)
A module is a function (or attrset) producing some of:
{ config, lib, pkgs, ... }:
{
imports = [ ./peer.nix ];
options = { /* option declarations */ };
config = { /* option definitions */ };
}| Field | Role |
|---|---|
imports |
DAG of other modules |
options |
Schema: types, defaults, descriptions |
config |
Values / definitions (often under mkIf) |
freeform / _module |
Advanced; respect unless you know why |
Evaluation merges all definitions according to type + priority, then checks types and assertions.
Freeform modules (awareness)
Some modules accept undeclared attrs (freeform types). That is convenient for “pass settings through” and dangerous for typos. Prefer explicit options for anything Capstone treats as policy.
Theory 2 — Type catalog (practical reference)
Import lib.types as types in real modules. Below is the catalog you should be able to choose from, not memorize constructor source.
Scalars & paths
| Type | Accepts | Notes |
|---|---|---|
types.bool |
true/false |
Prefer over “stringly” enables |
types.int / types.ints.positive / types.ints.u16 … |
Integers with bounds | Ports, counts |
types.port |
Port range | Prefer over bare int for listeners |
types.float |
Floats | Rare in OS config |
types.str |
String | Most text |
types.nonEmptyStr |
Non-empty string | Hostnames, user names |
types.singleLineStr |
No newlines | Avoid injection in unit files |
types.lines / types.commas |
Multi-line / comma merge | Distinct merge semantics |
types.path |
Path-like | Coerces some strings; store paths common |
types.package |
Derivation/package | For environment.systemPackages style |
types.shellPackage |
Package with shell | Specialized |
types.passwdEntry types.str |
Shadow-safe strings | Passwords (still don’t put secrets in store) |
types.strMatching "regex" |
Pattern | Domains, tokens shape |
types.enum [ "a" "b" ] |
Closed set | Modes |
types.either t1 t2 |
Union | e.g. bool or str |
types.oneOf [ t1 t2 … ] |
Multi union | Prefer small sets |
types.nullOr t |
null or t |
Optional values |
types.uniq t |
Unique merge | Only one definition wins (strict) |
types.anything |
Escape hatch | Avoid in public modules |
types.raw |
Untyped raw | Advanced / internal |
Collections
| Type | Merge behavior (intuition) |
|---|---|
types.listOf t |
Concatenate definitions |
types.attrsOf t |
Deep-merge by attr name |
types.lazyAttrsOf t |
Lazier attrs (perf / recursion) |
types.attrs |
Freeform attrs (weak) |
types.attrsWith { … } |
Configurable attrs (modern) |
types.loaOf t |
Deprecated list-or-attrs legacy |
Structure
| Type | Use |
|---|---|
types.submodule { options = …; } |
Nested option trees |
types.submoduleWith { modules = …; } |
Modular submodules |
types.deferredModule |
Module value delayed |
types.optionType |
Type of types (rare) |
Function-ish / paths to callables
| Type | Use |
|---|---|
types.functionTo t |
Options that are functions |
types.raw + convention |
When types can’t express it |
Useful helpers
types.addCheck t predicate
types.coercedTo from coerceFn to
lib.mkOption {
type = types.submodule {
options.enable = lib.mkEnableOption "this feature";
options.port = lib.mkOption {
type = types.port;
default = 8080;
};
};
default = { };
}lib.mkEnableOption "desc" → bool option, default false, standard description.
Choosing types under pressure
| Situation | Prefer |
|---|---|
| On/off feature | mkEnableOption / types.bool |
| Port | types.port |
| Hostname / domain | types.strMatching or nonEmptyStr + assertion |
| Multi-instance services | attrsOf (submodule …) |
| Free-form upstream JSON | attrs or structured submodule if you own schema |
| Secret material | Never as string in store; path option only |
Theory 3 — Definitions: mkIf, mkMerge, priorities
mkIf
config = lib.mkIf config.services.myapp.enable {
users.users.myapp = { isSystemUser = true; group = "myapp"; };
# ...
};False conditions drop definitions (they do not define null everywhere).
mkMerge
config = lib.mkMerge [
(lib.mkIf cfg.enable { … })
(lib.mkIf cfg.tls { … })
{ assertions = [ … ]; }
];Prefer mkMerge over huge nested // trees when multiple independent conditionals apply.
Priority ladder (high level)
| Helper | Intent |
|---|---|
mkOptionDefault |
Lowest-ish option default layer |
mkDefault |
Soft default; user config should win |
mkOverride n |
Numeric priority (mkDefault ≈ 1000, normal 100, …) |
mkForce |
High priority “I really mean this” |
mkFixStrictly / order-related |
Advanced ordering |
Discipline:
- Modules ship
mkDefaultfor opinionated defaults.
- Hosts use plain assignments or selective
mkForce.
- If you need
mkForceeverywhere, your module boundaries are wrong.
Example conflict
# module
services.openssh.enable = lib.mkDefault true;
# host
services.openssh.enable = false; # wins over mkDefault# role module
networking.firewall.enable = lib.mkForce true;
# host tries
networking.firewall.enable = false; # loses unless higher overrideDebugging silent loss
When a setting “does nothing”:
- Search all definitions of that option path.
- Check for
mkDefaultvs plain vsmkForce.
- Confirm the defining module is actually
imports-ed.
- Confirm
mkIfcondition is true.
- Confirm type merge didn’t drop invalid values (eval error should usually surface).
Theory 4 — Assertions & warnings
config = {
assertions = [
{
assertion = !(cfg.enable && cfg.package == null);
message = "myapp: enable requires package";
}
{
assertion = cfg.port != 22 || !config.services.openssh.enable;
message = "myapp: port 22 conflicts with OpenSSH on this host pattern";
}
];
warnings = lib.optional cfg.legacyMode ''
myapp.legacyMode is deprecated; migrate to settings.v2
'';
};Assertions fail evaluation—perfect for Capstone CI (nix flake check).
| Mechanism | Fails eval? | Use |
|---|---|---|
| Type check | Yes | Shape of values |
assertions |
Yes | Cross-option policy |
warnings |
No | Deprecations, soft policy |
| Activation scripts | No (too late for CI purity) | Runtime environment |
Theory 5 — Submodules & attrsOf services
Pattern for multi-instance:
options.services.myapp.instances = lib.mkOption {
type = types.attrsOf (types.submodule ({ name, ... }: {
options = {
enable = lib.mkEnableOption "instance ${name}";
port = lib.mkOption { type = types.port; };
};
}));
default = { };
};Then map instances to systemd units with lib.mapAttrs'.
# sketch
systemd.services = lib.mapAttrs' (name: inst:
lib.nameValuePair "myapp-${name}" (lib.mkIf inst.enable {
description = "myapp ${name}";
# …
})
) cfg.instances;Watch for name collisions and firewall port unique assertions across instances.
Theory 6 — Import graphs and role modules
| Pattern | When |
|---|---|
hosts/lab imports profiles/server.nix |
Role defaults |
profiles/server imports modules/ssh.nix + modules/hardening.nix |
Composition |
Host sets my.role = "edge" and one module switches |
Fewer imports; more logic |
Prefer shallow import graphs you can draw. Deep diamond imports with conflicting mkForce are how priorities become superstition.
Worked example — Redesign sketch
Before (weak)
{ config, lib, ... }:
{
options.my.proxy = lib.mkOption {
type = lib.types.attrs;
default = { };
};
config = {
# hope the attrs are right...
};
}After (typed)
{ config, lib, pkgs, ... }:
let
inherit (lib) types mkIf mkOption mkEnableOption;
cfg = config.my.proxy;
in {
options.my.proxy = {
enable = mkEnableOption "edge reverse proxy front door";
domain = mkOption {
type = types.strMatching "[a-z0-9.-]+";
example = "lab.example.com";
description = "Primary public hostname";
};
upstream = mkOption {
type = types.str;
example = "http://127.0.0.1:8080";
};
listenPort = mkOption {
type = types.port;
default = 443;
};
acme = mkOption {
type = types.bool;
default = true;
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.listenPort != 0;
message = "my.proxy.listenPort must be non-zero";
}
{
assertion = !(cfg.acme && cfg.listenPort == 80);
message = "my.proxy: ACME mode expects non-80 exclusive conflict check — adjust to your design";
}
];
# wire services.nginx / caddy / traefik here with mkDefault
};
}Bad configs that must fail
# wrong type
my.proxy.listenPort = "443";
# failed assertion
my.proxy.enable = true;
my.proxy.listenPort = 0;Save stderr as CI fixtures later (Capstone rebuild proof).
Lab 0 — Workspace
mkdir -p ~/lab/nixos/internals/modules
cd ~/lab/nixos/internals/modulesCopy or reference one real module from your lab flake (proxy, app, or secrets-adjacent).
Lab 1 — Type catalog cheat sheet (you write it)
In TYPES.md, list at least 15 types from the catalog with:
- one-line purpose
- one good example value
- one bad value that should fail
Do not paste the table from this chapter blindly—rewrite in your words.
Acceptance: 15 rows in your own language; at least 3 collection/structure types.
Lab 2 — Redesign one module
- Identify untyped
attrs, raw strings for ports, or missingmkEnableOption.
- Rewrite options with proper types + descriptions.
- Move conditionals under
mkIf/mkMerge.
- Add ≥2 assertions (conflict + required field).
- Ensure host still evaluates:
nix eval .#nixosConfigurations.<host>.config.system.build.toplevel --raw >/dev/null
# or
nixos-rebuild build --flake .#<host>Acceptance: Diff shows types + assertions; toplevel builds.
Lab 3 — Priority drill
Create a tiny three-file demo (can be pure lib.evalModules):
# eval-demo.nix — sketch
{ lib ? (import <nixpkgs> { }).lib }:
lib.evalModules {
modules = [
({ lib, ... }: {
options.value = lib.mkOption { type = lib.types.int; };
config.value = lib.mkDefault 1;
})
({ lib, ... }: {
config.value = 2; # plain
})
({ lib, ... }: {
# toggle these in experiments:
# config.value = lib.mkForce 3;
})
];
}nix eval -f eval-demo.nix config.valueRecord the winner for: default vs plain vs force. Paste results into PRIORITIES.md.
Lab 4 — Intentional bad config
Against your redesigned module, write bad-host.nix snippets that must fail eval (wrong type, failed assertion). Run them and save stderr excerpts—these become CI fixtures later.
Acceptance: Two distinct failure classes with captured stderr.
Lab 5 — Multi-instance sketch (optional)
If your Capstone has only one service instance, still sketch attrsOf submodule for a fictional second instance and document how ports would be asserted unique.
Lab 6 — Description quality audit
Pick five options you own. Ensure each has a description a stranger could use. Missing descriptions are how “options search” fails future you.
# explorative: use nixos-option on a live system or docsFailure modes
| Symptom | Fix |
|---|---|
| Option used but not defined | Declare option or fix path typo |
| Settings silently ignored | Priority war; search all definitions |
| Infinite recursion | Defaults cycle (evaluator chapter) |
listOf duplicates explode unit names |
lib.unique or set-like design |
| Assertion message useless | Put what and where in message |
Common gotchas
| Symptom | What to do |
|---|---|
The option … is used but not defined |
Missing option decl or wrong path |
| Infinite recursion in modules | Option default depends on own config cycle (evaluator) |
| Settings silently ignored | Lost priority war; check mkDefault/mkForce |
listOf duplicates explode |
Dedup with lib.unique or design set-like options |
Submodule names vs name arg |
Use { name, ... }: pattern carefully |
| Secrets in typed options | Types don’t encrypt; still use sops/agenix |
Over-using types.attrs |
Prefer attrsOf submodule or explicit options |
| Assertions only on happy path | Add at least one negative fixture |
Checkpoint
TYPES.mdwith ≥15 types in your words
- One production module redesigned with real types
- ≥2 assertions
- Priority demo understood (
PRIORITIES.md)
- Bad configs fail at eval with clear messages
- Host/toplevel still builds
- Three personal gotchas
Commit
git add .
git commit -m "lab(nixos): module types catalog and redesigned module"Write three personal gotchas before continuing.
Next
→ Store and daemon — multi-user trust, GC roots, sandbox, signatures.