Shared modules across hosts

Updated

July 30, 2026

Shared modules across hosts

Goal: Refactor fleet config into roles and profiles so hosts become thin compositions—no copy-paste SSH/firewall/observability blocks.

Why it matters

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.nginx.enable = true;
    # services.nginx.virtualHosts.${cfg.domain} = { … };
    environment.systemPackages = [ pkgs.curl ];
  };
}
# hosts/edge/configuration.nix
{
  imports = [
    ../../modules/profiles/baseline.nix
    ../../modules/roles/web.nix
  ];
  roles.web = {
    enable = true;
    domain = "edge.example.com";
  };
  networking.hostName = "edge";
}

Hosts should read like a shopping list, not a novel.


Theory 3 — Profile module pattern

# modules/profiles/baseline.nix
{ lib, pkgs, ... }:
{
  services.openssh = {
    enable = true;
    settings.PasswordAuthentication = false;
  };
  environment.systemPackages = [ pkgs.vim pkgs.git pkgs.htop ];
  time.timeZone = lib.mkDefault "UTC";
  i18n.defaultLocale = lib.mkDefault "en_US.UTF-8";
}
# modules/profiles/hardened.nix
{
  imports = [ ./baseline.nix ];
  # tighter ssh, sysctl, etc.—compose, don’t fork baseline forever
}
Profile Intent
baseline Every host
hardened Prod-ish
workstation GUI/dev laptops
lab Relaxed for learning

Theory 4 — Options API design

Practice Why
mkEnableOption Clear on/off
Typed options Catch typos at eval
mkDefault in profiles Hosts can override
mkForce sparingly Fight club otherwise
Document example Next human
options.roles.db = {
  enable = lib.mkEnableOption "database role";
  package = lib.mkPackageOption pkgs "postgresql_16" { };
  listenAddresses = lib.mkOption {
    type = lib.types.listOf lib.types.str;
    default = [ "127.0.0.1" ];
  };
};

Theory 5 — Directory layout that scales

flake.nix
modules/
  profiles/
    baseline.nix
    hardened.nix
  roles/
    web.nix
    db.nix
  services/
    myapp.nix
  common/
    sops.nix
    networking-lab.nix
hosts/
  edge/
    configuration.nix
    disko.nix
    hardware.nix
  db/
    configuration.nix
    disko.nix
Rule Detail
No hosts/* copying firewalls Profiles/roles
Disko/hardware only under host Physical truth
Secrets wiring shared; keys host-specific sops creation rules

Theory 6 — SpecialArgs and lib helpers

nixpkgs.lib.nixosSystem {
  specialArgs = { inherit inputs; myLib = import ./lib; };
  modules = [ ./hosts/edge ];
};

Keep helpers pure and small. A 2k-line lib/default.nix becomes a second language.


Theory 7 — Testing shared modules

Roles without tests drift. Preview:

# tests/web-role.nix — nixosTest imports roles.web
nodes.machine = {
  imports = [ ../modules/roles/web.nix ];
  roles.web.enable = true;
  roles.web.domain = "test.local";
};

Full harness in the nixosTest chapter—design modules so tests can import them without host hardware.


Worked example — before/after host thinness

Before (bad)

# hosts/edge/configuration.nix — 200 lines of ssh, firewall, nginx, users…

After (good)

{
  imports = [
    ./hardware.nix
    ./disko.nix
    ../../modules/profiles/hardened.nix
    ../../modules/roles/web.nix
    ../../modules/common/sops.nix
  ];
  networking.hostName = "edge";
  networking.hostId = "deadbeef"; # if needed
  roles.web = {
    enable = true;
    domain = "edge.example.com";
  };
  # host-only networking addresses, etc.
  system.stateVersion = "26.05";
}

Measure: host file line count before/after; aim for sharp drop.


Worked example — flake wiring both deploy tools

# same modules feed:
# - nixosConfigurations.edge
# - colmena.edge
# - deploy.nodes.edge
# - nixos-generators image for edge

One module graph is the entire point of this chapter.


Lab — multi-step

Suggested work: your fleet flake

Step 1 — Inventory duplication

rg -n "openssh|allowedTCPPorts|nginx" hosts modules | head

List copy-pasted blocks.

Step 2 — Extract profiles/baseline.nix

SSH + packages + stateVersion defaults. Import from two hosts.

Step 3 — Extract one role

roles.web or roles.lab-tooling with enable flag.

Step 4 — Thin hosts

Move host-specific only. Commit before/after line counts in notes.

Step 5 — Override test

Use lib.mkForce or host option once deliberately; document who wins.

Step 6 — Wire into Colmena/deploy-rs

Prove both nodes still deploy/eval.

Step 7 — Option docs

Add example and a one-line comment per role option.

Step 8 — Negative test

Enable role without required option; ensure eval fails clearly.

Step 9 — README map

# modules/README.md
profiles/ — …
roles/ — …

Exercises

  1. Design options for a roles.observer (metrics scrapes only).
  2. Draw merge priority: baseline vs hardened vs host.
  3. Find three nixpkgs modules that are good style models; note patterns.
  4. Refactor a third host using only imports + three option sets.
  5. Write a “no mkForce without comment” team rule.

Common gotchas

Symptom / mistake What to do
Role enables services globally without mkIf Always gate on cfg.enable
Host still 500 lines Keep extracting
Option name typos Types + eval tests
Circular imports Layer strictly
Hardware in roles Move to host
Silent mkForce wars Prefer defaults + host overrides
specialArgs secret strings Paths only
Untested roles nixosTest next

Checkpoint

  • Baseline profile shared by ≥2 hosts
  • At least one role module with options
  • Hosts thinner (measured)
  • Deploy/hive still evaluates
  • modules README map
  • Clear enable-option gating

Commit

git add .
git commit -m "ops: shared roles and profiles"

Write three personal gotchas before images.