The Configuration Pipeline
Overview
Section titled “Overview”Nest evaluates your configuration in one deterministic pipeline. Each step builds on the previous:
1. DOM walk attrset hierarchy → flat node list2. Trait expansion is = [web] → is = [web, server, nginx, ssh, firewall]3. neededBy auto-inject traits based on selectors4. Trait synth compute derived attrs + inject virtual children5. Annotation match rules → merge class configs per node6. Rule synth inject rule-defined virtual children7. Output collect child frags → call class fns → byClassStep 1: DOM Walk
Section titled “Step 1: DOM Walk”traverseDom recurses your nest.* attrset. At each level it distinguishes:
- Namespace — no
iskey, orisis absent. Scalar attrs are collected asinheritedAttrsand passed down. The namespace itself is not a node. - Node — has
is = [...].A node is created with: inherited attrs (lowest priority) + node own attrs + synth attrs (highest).
nest.prod.system = "x86_64-linux"; # namespace scalar → inheritednest.prod.env = "prod"; # namespace scalar → inherited
nest.prod.web = { # node is = [ nest.host ]; addr = "10.0.0.2"; # system and env inherited from prod namespace};Step 2: Trait Expansion
Section titled “Step 2: Trait Expansion”expandTraits resolves the needs DAG for each node’s is list:
# Before:# web.needs = [ nest.server ]# server.needs = [ nest.nginx nest.ssh nest.firewall ]is = [ nest.web ]
# After expandTraits:is = [ nest.web nest.server nest.nginx nest.ssh nest.firewall ]Expansion is deduplication-safe: each trait appears at most once regardless of how many paths lead to it.
Step 3: neededBy Injection
Section titled “Step 3: neededBy Injection”expandNeededBy checks every trait in the trait tree for a neededBy selector. If a node matches, that trait is added to the node’s is and expansion reruns (until fixpoint):
nest.trait.monitoring.neededBy = nest.server;
# Node has is = [… nest.server …]# → matches neededBy selector# → nest.monitoring injected into is# → monitoring's own needs expandedThis is fully automatic — servers gain monitoring without declaring it.
Step 4: Trait Synth
Section titled “Step 4: Trait Synth”synthesizeNodes runs trait synth functions bottom-up. Each synth receives the node’s select context (over the pre-synth node list) and returns:
{ node = { derivedAttr = value; # merged onto node children = [ { … } ]; # injected as DOM children };}Virtual children get proper path and parent, full trait expansion, and are added to the node list before step 5.
Step 5: Annotation (Rule Matching)
Section titled “Step 5: Annotation (Rule Matching)”annotateNodes builds a context cache and then matches rules to each node:
For each node: ctx = mkCtx node synthesizedNodes matchingRules = filter (r: matchesOne node r.is ctx) rules __mergedCfg = mergeRuleConfigs node matchingRules ctx__mergedCfg is an attrset keyed by class name (nixos, user, etc.). Each class key holds a list of contributions — one entry per matching rule. Nest does not deep-merge these; the NixOS module system merges them when nixosSystem is called.
Exception: the synth key deep-merges its results (synth produces plain attr derivations, not NixOS modules).
Function-valued rule class configs are called via callWithArgs, which injects:
select— the node’s select context- Entity arg — the node itself as
host = node,user = node, etc.
The special synth key in rules lands in __mergedCfg.synth and is consumed by applyRuleSynth.
Step 6: Rule Synth
Section titled “Step 6: Rule Synth”applyRuleSynth reads __mergedCfg.synth for each annotated node and applies it:
- Extract
nodeattrs andchildrenfrom the synth result - Merge attrs onto the node
- Inject children with proper paths and expanded traits
- Re-annotate new children with rules (full annotation pass)
This gives rule-defined virtual children the same treatment as static DOM children.
Step 7: Output
Section titled “Step 7: Output”processNode is called for each root node (nodes whose __parentPath has no match in finalAnnotated):
allMods = mergeModuleLists __mergedCfg (collectChildFrags node)select = mkCtx node finalAnnotatedvalue = classFns.${className} select allMods.${className}allMods.${className} is a list of module attrsets — rule contributions plus child contributions concatenated. The class fn receives this list and decides how to use it.
collectChildFrags recursively processes direct children. Each child’s class fn returns a { classKey = [modules] } attrset; those lists are concatenated into the parent’s module lists. This is how user children contribute nixos.users.users.* modules to their parent host.
The results are routed by class:
byClass = { nixos = { lb-prod = nixosSystem { … }; web-1 = nixosSystem { … }; }; homeManager = { alice = homeConfig { … }; };};
# flakeModule routes byClass to flake outputs:nixosConfigurations = byClass.nixos;homeConfigurations = byClass.homeManager;Module list ordering
Section titled “Module list ordering”For a given class key, the list passed to the class fn is ordered:
- Rule contributions — in rule list order, one entry per matching rule
- Child fragment contributions — appended after rule contributions
The class fn receives the complete list. For nixosSystem, all entries are passed as modules — the NixOS module system merges them with full priority semantics (lib.mkForce, lib.mkDefault, option type checking).
Node attribute priority (for select.node access) is separate and unchanged:
- Namespace-inherited attrs (lowest)
- Node own attrs
- Synth-derived attrs (from
trait.synthor rulesynth) - Framework-injected (
name,__path,__parentPath) (highest)