Skip to content

The Configuration Pipeline

Nest evaluates your configuration in one deterministic pipeline. Each step builds on the previous:

1. DOM walk attrset hierarchy → flat node list
2. Trait expansion is = [web] → is = [web, server, nginx, ssh, firewall]
3. neededBy auto-inject traits based on selectors
4. Trait synth compute derived attrs + inject virtual children
5. Annotation match rules → merge class configs per node
6. Rule synth inject rule-defined virtual children
7. Output collect child frags → call class fns → byClass

traverseDom recurses your nest.* attrset. At each level it distinguishes:

  • Namespace — no is key, or is is absent. Scalar attrs are collected as inheritedAttrs and 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 → inherited
nest.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
};

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.

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 expanded

This is fully automatic — servers gain monitoring without declaring it.

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.

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.

applyRuleSynth reads __mergedCfg.synth for each annotated node and applies it:

  1. Extract node attrs and children from the synth result
  2. Merge attrs onto the node
  3. Inject children with proper paths and expanded traits
  4. Re-annotate new children with rules (full annotation pass)

This gives rule-defined virtual children the same treatment as static DOM children.

processNode is called for each root node (nodes whose __parentPath has no match in finalAnnotated):

allMods = mergeModuleLists __mergedCfg (collectChildFrags node)
select = mkCtx node finalAnnotated
value = 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;

For a given class key, the list passed to the class fn is ordered:

  1. Rule contributions — in rule list order, one entry per matching rule
  2. 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:

  1. Namespace-inherited attrs (lowest)
  2. Node own attrs
  3. Synth-derived attrs (from trait.synth or rule synth)
  4. Framework-injected (name, __path, __parentPath) (highest)
Contribute Community Sponsor