Writing NixOS Modules People Actually Want to Use
A NixOS module is an API. Typed options, eval-time assertions, and secure defaults turn raw config into something teams configure in ten lines and can't get wrong.
- #iac
- #ai
- #nixos
- #nix
- #modules
- #declarative
There are two kinds of NixOS modules in the world. The first is a wall of config that someone copied into configuration.nix, edited by trial and error, and now nobody dares touch. The second is a clean module you enable with services.myapp.enable = true, configure through a handful of typed options that autocomplete and validate, and trust to do the right thing by default. The Nix language makes both equally possible — the difference is entirely in how the module’s options are designed.
NixOS’s module system is, at its core, an API design exercise. The options you expose are the contract; the config you generate is the implementation; and the assertions you write are the guardrails that keep consumers out of invalid states. This guide shows how to build the second kind of module.
Options are a typed, documented interface
The heart of a good module is the options tree. Every setting should have a type, a default, a description, and ideally an example — which together make the module self-documenting and discoverable through nixos-option. Compare a vague free-form config with a properly typed module:
{ config, lib, pkgs, ... }:
with lib;
let cfg = config.services.metricsproxy;
in {
options.services.metricsproxy = {
enable = mkEnableOption "the metrics proxy";
port = mkOption {
type = types.port;
default = 9090;
description = "Port the metrics proxy listens on.";
};
upstreams = mkOption {
type = types.listOf types.str;
default = [];
example = [ "http://app-01:8080" "http://app-02:8080" ];
description = "Backend targets to proxy metrics from.";
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = "Whether to open the listen port in the firewall. Off by default.";
};
};
}
The types.port and types.listOf types.str annotations mean Nix rejects a string where a port belongs, or a bare string where a list belongs, at evaluation time — before anything is built. The example shows up in generated documentation. A consumer never has to read the module’s implementation to understand what they can set.
Defaults should be secure and the module should be inert
Two principles make a module safe to depend on. First, everything in config is gated behind cfg.enable, so importing the module costs nothing until someone explicitly turns it on — essential for a module shipped in a shared flake that many hosts import. Second, defaults are secure and non-surprising: the firewall stays closed unless asked, the service runs as a least-privilege user, no port is exposed by accident.
config = mkIf cfg.enable {
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
systemd.services.metricsproxy = {
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${pkgs.metricsproxy}/bin/metricsproxy --port ${toString cfg.port}";
DynamicUser = true;
ProtectSystem = "strict";
NoNewPrivileges = true;
ReadOnlyPaths = [ "/" ];
};
};
};
The systemd hardening — DynamicUser, ProtectSystem, NoNewPrivileges — ties the module to least privilege by default. Because these defaults become every consumer’s defaults the moment they enable the module, an insecure default (an open firewall, a root user) propagates silently to everyone. That’s an argument for reviewing them carefully, not for skipping them.
Assertions move bugs from runtime to eval time
This is where Nix genuinely outshines imperative config management. Instead of letting a misconfiguration crash the daemon on boot, you catch it when the configuration evaluates:
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.upstreams != [];
message = "services.metricsproxy.upstreams must list at least one backend.";
}
{
assertion = !(cfg.openFirewall && cfg.port < 1024);
message = "Refusing to open privileged port ${toString cfg.port} in the firewall.";
}
];
# ... rest of config
};
A consumer who enables the proxy with no upstreams gets a clear, actionable error when they run nixos-rebuild build — not a service that starts and silently proxies nothing. Eval-time validation with good messages is one of the best reasons to use the module system at all, and it’s the part hand-rolled config can’t replicate.
Drafting modules with AI, verifying the defaults
Module boilerplate is repetitive enough for AI to draft well, and consequential enough that you check every default. A prompt:
You are a Nix engineer. Write a NixOS module
services.metricsproxywith typed options (port, upstreams list, openFirewall bool), secure defaults (firewall closed, DynamicUser),mkIf cfg.enablegating, and assertions for empty upstreams and privileged ports. Show consumer usage.
The model produced a clean module and flagged the assumption a person needs to confirm:
Here’s the module. One caveat: I used
DynamicUser = truewithProtectSystem = "strict", which means the service can’t write anywhere except explicitly granted paths. If metricsproxy needs a writable cache or state directory, it’ll fail to start — you’ll want aStateDirectoryorCacheDirectoryfor that. I left it out since your spec didn’t mention persistent state.
That’s the human-verifies half. The model can’t know the daemon’s filesystem needs, so it surfaced the constraint rather than guessing. Always evaluate with nixos-rebuild build (or dry-activate) before switch — a module that builds can still fail activation if a systemd unit can’t start, and you want to find that out without taking down the running host.
Build the interface, not just the config
The discipline that makes NixOS modules pleasant is treating them as APIs: typed options with descriptions, secure defaults, eval-time assertions, and enable-gated inertness. Get those right and consumers configure your module in ten lines and can’t easily set it into a broken state — which is the entire promise of declarative infrastructure.
For generating modules, see our NixOS module and options design prompt, and pair it with the Nix flakes prompt for the reproducible packaging around them. The Infrastructure as Code category covers the rest of the reproducible-infrastructure toolchain. Design the options first — they’re the contract everyone else lives with.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.