A useful module leaves the small choices to the machine using it. We’re going to build one that lets another file write only this:
The module will decide what those settings mean, reject nonsense, and turn the answer into real NixOS configuration.
Every module you have written so far only set options that NixOS had already declared. One kind of line, all the way down.
A module that brings its own options has two kinds, and from the outside they
look identical. services.my-program.port = … could be this file declaring that
the option exists, or it choosing a value for one. So the module system asks you
to say which. options holds what another file may choose. config holds what
this module adds to the machine once every file’s choices have been merged.
That split is also why config stops being optional. While a module only
answers, NixOS lets you drop the wrapper and reads the whole file as config.
The moment options appears at the top level, that shorthand is off for the
entire file, and a bare networking.firewall beside it stops being a setting.
It will tell you so, and the fix is always the same: move it down into config.
The module below has just grown its first declaration, and the line underneath it
has not caught up.
The firewall line stopped being a setting when the declaration arrived. Put it back where settings go.
- Move
networking.firewall.enabledown under aconfighalf
Everything else in this lesson goes in one half or the other.
Nearly every service starts with the same option: an off-by-default boolean
that the whole module hangs on. lib.mkEnableOption writes that one for you.
It takes the words that finish the sentence “Whether to enable …”:
{ lib, ... }:
{
options = {
services.tea-kettle.enable = lib.mkEnableOption "the tea kettle";
};
config = { };
}
That single line declares a bool, defaults it to false, and writes the
documentation. Now do the same for our program, and put it under one path:
related options sharing a namespace is what keeps a service’s settings from
scattering across the machine.
Declare services.my-program.enable with the label "my program service".
- Declare the
enableoption
mkEnableOption is the shorthand. Everything it fills in silently is what the
next step writes out by hand.
A port has more to say than yes or no, so it gets the full form.
lib.mkOption takes an attribute set describing the choice:
{ lib, ... }:
{
options = {
services.tea-kettle.greeting = lib.mkOption {
type = lib.types.str;
default = "hello";
example = "good evening";
description = "Line the kettle prints when it boils.";
};
};
config = { };
}
type is the gate every value has to pass. default is what the machine gets
when nobody chooses. example and description are for the person reading the
generated documentation. Write the same four for port.
Declare port as a real TCP port with default 3000, example 8080, and a description.
- Declare
portwithlib.mkOption - Use the
lib.types.porttype - Set the default to
3000 - Set the example to
8080 - Add a
description
Use lib.types.port, not a plain integer. It refuses values below zero and
above 65535 before your program gets anywhere near them, so a typo becomes a
build error instead of a service that will not start.
Two of those four fields hold a port number, and only one of them is a value the machine will ever use.
Nobody sets services.my-program.port. Which port does the machine end up with?
default changes the configuration. example never does: it appears in the
generated documentation and nowhere else. An option declared with neither is
the third answer, and asking for its value is an error until somebody sets it.
Declaring an option changes nothing on its own. The config half has to read
the merged answer back and do something with it, and it reads it from config,
the same argument you met when a firewall rule watched openssh. Every module
that does this gives that path a short name first:
{ config, lib, ... }:
let
cfg = config.services.tea-kettle;
in
{
options = { };
config = { };
}
cfg is the answer after every file has had its say, not a copy of whatever
one of them happened to write. Below, use it with lib.optional, the helper
from the lib lesson that produces a one-item list when its condition is true
and an empty one when it is false, so the port is open only while the switch is
on.
Open the selected port only while my-program is enabled.
- Use
cfg.enableandcfg.portto build the firewall list
That is a complete module. It declares a small set of choices, and turns the answer into ordinary NixOS options that the rest of the machine already knows how to read.
Now do the real two-file move. The workbench evaluates both files together with
the actual module system. Add the missing port declaration to my-program.nix,
then configure it from host.nix.
Declare port as lib.types.port, then enable my-program on port 8080.
evaluated system configuration
Every module is switched off, so there is nothing to build.
The option file owns the rules and the effect. The host file only chooses
values. Neither file reaches into the other one. The module system joins them,
checks the values, and hands my-program.nix the final answer through config.
- Put the settings a caller may choose under
options, and what follows from them underconfig. - Use
mkEnableOptionfor the usual off-by-default switch. - Use
mkOptionto give every richer value a type, default, example, and description. - The type is a gate, and
defaultis the only one of the four that reaches the machine. - Read merged values back through
config, usually namedcfgat the top of the file. - Keep declarations and their effect in one module, then set them from another.


Share your thoughts