Day 19 — First custom module
Day 19 — First custom module
Stage II · ~4h
Goal: Author a small local module with options + config, import it into the host, gate behavior with mkIf, and fail early with an assertion.
Why this day exists
Stage II ends with a production-shaped host—not a 400-line configuration.nix. Custom modules are how you name intent (myLab.monitoring.enable) instead of scattering unrelated assignments.
Theory 1 — Minimal custom module shape
modules/hello-lab.nix:
{ config, lib, pkgs, ... }:
let
cfg = config.myLab.hello;
in {
options.myLab.hello = {
enable = lib.mkEnableOption "hello lab demo module";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.hello;
description = "Package providing the hello binary";
};
greetInterval = lib.mkOption {
type = lib.types.str;
default = "hourly";
description = "systemd OnCalendar expression for greeting timer";
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
systemd.services.my-lab-hello = {
serviceConfig = {
Type = "oneshot";
ExecStart = "${cfg.package}/bin/hello";
};
};
systemd.timers.my-lab-hello = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = cfg.greetInterval;
Persistent = true;
Unit = "my-lab-hello.service";
};
};
};
}Theory 2 — mkOption and types (starter set)
| Type | Use |
|---|---|
lib.types.bool |
Flags |
lib.types.str |
Strings |
lib.types.int |
Integers |
lib.types.package |
Packages |
lib.types.listOf lib.types.str |
Lists |
lib.types.attrsOf … |
Maps |
lib.types.port |
Ports |
lib.types.nullOr … |
Optional |
Deep type catalogs come later. Today: bool + package + str is enough.
mkEnableOption "doc" → boolean option with standard description pattern.
Theory 3 — cfg = config.… pattern
Always bind:
let cfg = config.myLab.hello; in …Then mkIf cfg.enable and read cfg.package. Avoid long repeated paths; avoid accidental recursion by not defining options in terms of themselves carelessly.
Theory 4 — Assertions
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.greetInterval != "";
message = "myLab.hello.greetInterval must be non-empty";
}
];
};Assertions fail evaluation with a clear message—better than a mysterious empty timer.
Also available: warnings = [ "…" ]; for non-fatal nags.
Theory 5 — Import into the host
hosts/lab/configuration.nix:
{
imports = [
./hardware-configuration.nix
../../modules/hello-lab.nix
];
myLab.hello.enable = true;
# myLab.hello.greetInterval = "daily";
}Or list modules in flake.nix modules = [ … ].
Theory 6 — Namespace your options
| Good | Risky |
|---|---|
myLab.* / myOrg.* |
Top-level inventing services.foo that collides |
| Clear enable flags | Global options without prefix |
Use a personal/org prefix until you contribute upstream.
Theory 7 — What to extract first
Extract cohesive features, not random lines:
| Good extraction | Weak extraction |
|---|---|
| “lab SSH hardening bundle” | One unrelated package |
| “tick timer feature” | Half a firewall + half a user |
| “admin tools profile” | Entire host config as one module forever |
God modules become tomorrow’s spaghetti.
Worked examples bank
Example A — Enable site
myLab.hello.enable = true;Example B — Assertion trip
Set an invalid option deliberately; rebuild; read error; fix.
Example C — Package override
myLab.hello.package = pkgs.hello; # or another pkg with similar interface carefullyLab 1 — Create module file
mkdir -p ~/nixos-config/modules
# write modules/hello-lab.nix (Theory 1)Import + enable. Rebuild:
cd ~/nixos-config
sudo nixos-rebuild switch --flake .#lab
hello
systemctl list-timers | grep my-lab-hello || trueLab 2 — Add a real assertion
Extend options with something checkable, e.g.:
port = lib.mkOption {
type = lib.types.port;
default = 22;
};Assert port != 0 or that firewall contains the port when a hypothetical service is enabled—keep it simple but real.
Lab 3 — Extract something from your host config
Identify 10–40 lines in configuration.nix (packages bundle, SSH settings, timer) and move into modules/<feature>.nix with an enable flag.
Before/after: host file should get shorter and more intentional.
Lab 4 — Disable path
myLab.hello.enable = false;Rebuild; confirm packages/units from the module disappear (or stop being enabled). Re-enable.
Lab 5 — Document the module
modules/README.md:
# modules
## hello-lab
Options: myLab.hello.*
Purpose: …Theory 8 — mkDefault vs bare assignment (producer side)
Inside your module’s config, prefer leaving room for host overrides:
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
# If you set a global-ish value, consider mkDefault so hosts can override:
# networking.firewall.allowPing = lib.mkDefault true;
};| Helper | Intent |
|---|---|
bare = |
Force (subject to merge priority rules) |
mkDefault |
Soft default |
mkForce |
Rare; win conflicts deliberately |
Day 18 covered consumer-side mkDefault; today you emit values carefully.
Theory 9 — Option descriptions are UX
greetInterval = lib.mkOption {
type = lib.types.str;
default = "hourly";
example = "daily";
description = ''
systemd OnCalendar expression for the greeting timer.
Prefer named calendars like hourly/daily while learning.
'';
};Future you reads nixos-option output under pressure—write for that moment.
Theory 10 — Module args you receive
{ config, lib, pkgs, modulesPath, ... }:| Arg | Use |
|---|---|
config |
Read final/merged values (cfg = config.…) |
lib |
mkOption, mkIf, types, … |
pkgs |
Packages for defaults and units |
modulesPath |
Import nixpkgs NixOS modules by path (advanced) |
Do not add custom top-level args unless you wire specialArgs / _module.args intentionally (later).
Worked example D — warnings for soft policy
config = lib.mkIf cfg.enable {
warnings =
lib.optional (cfg.greetInterval == "minutely")
"myLab.hello.greetInterval = minutely will spam logs on the lab host";
};Lab 6 — nixos-option on your option
nixos-option myLab.hello.enable
nixos-option myLab.hello.greetIntervalIf the option is missing, the module is not imported into the evaluated system.
Lab 7 — Priority conflict drill
Set the same option in host config and inside the module (with/without mkDefault). Rebuild and note which wins. Write one sentence on the result in modules/README.md.
Common gotchas
| Symptom | Theory |
|---|---|
| Infinite recursion | options/config cycle |
| Option not found | Not imported |
| Type error | Wrong types.* |
| Module always on | Forgot mkIf |
| Colliding option names | Namespace |
| Assertion silent | Not inside merged config properly |
Checkpoint
- Custom module with options + config
mkIfon enable
- At least one assertion
- Imported into host flake
- Extracted real config into a feature module
- Enable/disable verified
Commit
cd ~/nixos-config
git add .
git commit -m "day19: first custom module options assertions"Tomorrow
Day 20 — Boot & hardware. systemd-boot vs GRUB, kernels, firmware—document your lab’s boot path.