Day 61 — Shared modules & roles

Updated

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 shame

Worked 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

# bad: a imports b imports a

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

git add .
git commit -m "day61: roles and shared baseline modules"

Write three personal gotchas before continuing.

Tomorrow

Day 62 — Images & nixos-generators: cloud images and custom ISOs from the same modules.