Day 61 — Shared modules & roles
July 30, 2026
Day 61 — Shared modules & roles
Stage VI · ~4h
Goal: Refactor fleet config into roles and profiles so hosts become thin compositions—no copy-paste SSH/firewall/observability blocks.
Why this day exists
Two Colmena nodes already tempt duplication. Ten nodes without roles guarantee drift: one host missing a hardening line is an incident. Modules are the compression algorithm of NixOS fleets.
Theory 1 — Layering
nixpkgs modules
└─ your library modules (roles/, profiles/, services/)
└─ host modules (hosts/edge)
└─ secrets / hardware / disko (host-specific)
| Layer | Contains | Avoid |
|---|---|---|
| Role | web, db, observer — purpose |
Hardware paths |
| Profile | baseline, hardened, workstation |
One-off hacks |
| Host | hostname, addresses, disk, keys | Recreating baseline |
| Service module | Single app options | Global side effects silent |
Theory 2 — Role module pattern
# modules/roles/web.nix
{ config, lib, pkgs, ... }:
let
cfg = config.roles.web;
in {
options.roles.web = {
enable = lib.mkEnableOption "web edge role";
domain = lib.mkOption {
type = lib.types.str;
example = "service.example.com";
};
};
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [ 80 443 ];
services.caddy.enable = true;
services.caddy.virtualHosts.${cfg.domain}.extraConfig = ''
respond "ok"
'';
# or nginx—match Stage IV choice
};
}# hosts/edge/default.nix
{
imports = [
../../modules/profiles/baseline.nix
../../modules/roles/web.nix
];
roles.web = {
enable = true;
domain = "edge.lab.internal";
};
networking.hostName = "edge";
}Theory 3 — Baseline profile
# modules/profiles/baseline.nix
{ lib, pkgs, ... }:
{
time.timeZone = "UTC";
i18n.defaultLocale = "en_US.UTF-8";
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "prohibit-password";
};
};
environment.systemPackages = with pkgs; [ vim curl jq htop ];
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
auto-optimise-store = true;
};
networking.firewall.enable = true;
}# modules/profiles/hardened.nix
{
imports = [ ./baseline.nix ];
# Day 36 patterns: sysctl, auditd optional, disable unused
# Keep reversible for lab
}Theory 4 — specialArgs and inventory
# flake / colmena meta
specialArgs = {
inventory = {
domain = "lab.internal";
adminKeys = [ "ssh-ed25519 AAAA…" ];
};
};# modules/profiles/baseline.nix
{ inventory, pkgs, ... }:
{
users.users.root.openssh.authorizedKeys.keys = inventory.adminKeys;
}Hosts remain data; modules remain behavior.
Theory 5 — Assertions and defaults
{ config, lib, ... }:
{
config = lib.mkIf config.roles.web.enable {
assertions = [
{
assertion = config.roles.web.domain != "";
message = "roles.web.domain must be set";
}
];
};
}services.openssh.settings.PasswordAuthentication = lib.mkDefault false;
# host may mkForce true only with comment of shameWorked example — directory layout
flake.nix
modules/
profiles/baseline.nix
profiles/hardened.nix
roles/web.nix
roles/devbox.nix
services/myapp.nix
hosts/
edge/default.nix
core/default.nix
edge/disko.nix
core/disko.nix
Colmena defaults = { imports = [ ./modules/profiles/baseline.nix ]; … } or import baseline per host—pick one to avoid double-import.
Lab — multi-step
Suggested notes: ~/lab/90daysofx/02-nixos/day61
Step 1 — Inventory duplication
rg -n "openssh|PasswordAuthentication|timeZone" hosts modules and list triplicates.
Step 2 — Extract profiles/baseline.nix
Remove duplicated chunks from hosts; rebuild/deploy still works.
Step 3 — Create one role
roles.web or roles.devbox with mkEnableOption. Enable on one host only.
Step 4 — Second host consumes baseline only
Prove role is optional.
Step 5 — Assertion
Add assertion; break it on purpose; fix.
Step 6 — mkDefault vs host override
Show host override of a baseline package list or setting with comment.
Step 7 — Document module contract
Each role file starts with a 5-line comment: purpose, ports, state dirs, secrets needed, who may enable.
Step 8 — Deploy
Colmena/deploy-rs apply; no behavior regression.
Theory 6 — Host vs role vs profile (decision tree)
Is it true for every machine in the fleet?
yes → modules/baseline or common/*
Is it true for a class (web, db, laptop)?
yes → modules/roles/<role>.nix
Is it true for one hostname only?
yes → hosts/<name>/*.nix
Mis-layering causes “why did the DB host get the laptop touchpad module?”
Theory 7 — Import graphs and cycles
Prefer a DAG: flake → host → roles → baseline. Share via imports = [ … ], not mutual imports. If you need shared options, put option declarations in a thin options.nix imported by both.
Worked example — role enable pattern
# modules/roles/web.nix
{ config, lib, ... }:
let cfg = config.myOrg.roles.web; in {
options.myOrg.roles.web.enable = lib.mkEnableOption "web edge role";
config = lib.mkIf cfg.enable {
imports = [ ../services/proxy.nix ];
# myOrg.roles.web specific asserts
};
}Hosts only flip myOrg.roles.web.enable = true.
Lab — draw the import DAG
ASCII in docs/modules-dag.md for your real flake. If you cannot draw it, simplify.
Lab — extract one host-only blob
Find 15+ lines only relevant to one host still sitting in common modules. Move to hosts/<name>/. Rebuild both mental models: common shrinks, host clarifies.
Theory 8 — Naming conventions for shared options
| Pattern | Example |
|---|---|
| Org prefix | myOrg.roles.web.enable |
| Feature modules | myOrg.services.proxy.enable |
| Avoid bare | enableWeb at top level |
Consistent prefixes make nixos-option and search usable across hosts.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Double imports | Import baseline once |
| Role enables heavy services globally | Gate with mkIf cfg.enable |
| Host still 400 lines | Extract again; hardware alone may stay long |
specialArgs missing |
Thread through nixosSystem / colmena meta |
| Priority wars | Learn mkForce / mkDefault; document |
| Circular imports | Unidirectional: hosts → roles → not back |
Checkpoint
- Baseline profile shared
- At least one role module with options
- Hosts are thin compositions
- Assertion demonstrated
- Module contracts commented
Commit
Write three personal gotchas before continuing.
Tomorrow
Day 62 — Images & nixos-generators: cloud images and custom ISOs from the same modules.