Shared modules across hosts
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)
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 edgeOne 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
Exercises
- Design options for a
roles.observer(metrics scrapes only).
- Draw merge priority: baseline vs hardened vs host.
- Find three nixpkgs modules that are good style models; note patterns.
- Refactor a third host using only imports + three option sets.
- 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
Write three personal gotchas before images.