Day 19 — First custom module

Updated

July 30, 2026

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 carefully

Lab 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 || true

Lab 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.greetInterval

If 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
  • mkIf on 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.