Skip to content

Rules

Rules live in nest.rules, as a list or as an attrset keyed by selector or label (see Rule forms). Each rule has:

  • is — selector determining which nodes this rule applies to
  • One or more class keys (nixos, user, etc.) — config contributed to matching nodes
nest.rules = [
{
is = nest.host; # selector
nixos = { }; # class key: contributes to nixos class config
}
];

nest.rules accepts three shapes, all normalised to the same internal list:

# 1. List form — an explicit `is` per rule:
nest.rules = [ { is = nest.host; nixos = { }; } ];
# 2. Selector-keyed — the attrset KEY is the selector string:
nest.rules."host > user".user = { };
# 3. Label-keyed — the key is just a label, the selector lives in `is`
# (handy when the selector is a DSL value rather than a string):
nest.rules.hostDefaults = { is = nest.host; nixos = { }; };

Any selector form:

is = nest.host
is = [ nest.host nest.web ]
is = [ nest.host (nest.has nest.admin) ]
is = nest.attrs { env = "prod"; }
is = "*.web"

Each key besides is is a class key. It contributes config to the matching node’s merged config for that class.

{
is = nest.nginx;
nixos.services.nginx.enable = true;
}

When the value is a function, it receives named args it declares:

{
is = nest.host;
nixos = { host, select, ... }:
{
networking.hostName = host.name;
networking.extraHosts =
lib.concatMapStringsSep "\n"
(p: "${p.addr} ${p.name}")
(select.siblings nest.host);
};
}

Available args:

ArgTypeDescription
selectselect contextSelect API for this node
nodenodeThe matched node, by a fixed name
entity argnodeThe node itself, keyed by entity trait name (host, user, alice, etc.)

Only declare the args you need — unused args are filtered out.

An argument nest cannot resolve from the node’s context — such as pkgs, lib, or config — is deferred: nest hands the function to the NixOS module system, which supplies those arguments at evaluation time. So a rule can ask for pkgs and use it exactly like a normal module:

{
is = nest.host;
nixos = { pkgs, ... }: { environment.systemPackages = [ pkgs.git ]; };
}
# host entity
nixos = { host, ... }: { networking.hostName = host.name; }
# user entity
user = { user, ... }: { isNormalUser = true; openssh.authorizedKeys.keys = user.sshKeys; }
# any node — just use select
nixos = { select, ... }: { result = select.node.someAttr; }

When multiple rules match a node, their contributions are collected as a list — one entry per matching rule. Nest does not deep-merge them. The class fn receives the full list and passes it to nixosSystem, which lets the NixOS module system handle merging with proper semantics (lib.mkForce, lib.mkDefault, type checking, conflict detection):

nest.rules = [
{ is = nest.host; nixos.networking.hostName = "default"; }
{ is = "#web-1"; nixos.networking.hostName = "web-1"; }
# Both entries go into the modules list passed to nixosSystem.
# NixOS sees two definitions for networking.hostName and merges by option type.
];

Multiple rules targeting different keys compose naturally:

{ is = nest.host; nixos.nix.settings.experimental-features = [ "nix-command" ]; }
{ is = nest.nginx; nixos.services.nginx.enable = true; }
# For a node with both host and nginx:
# nixosSystem receives both attrsets as separate modules — no nest-level merge needed

A rule can carry a synth key — processed identically to trait synth but fired by rule matching:

{
is = [ nest.host (nest.attrs { env = "prod"; }) ];
synth = { select, ... }: {
node.children = map (u: {
inherit (u) name sshKeys;
is = [ nest.user nest.admin ];
}) (select nest.admin);
};
}

Rule synth runs after all rule matching completes. Synthesized children are then re-evaluated against all rules — they participate fully in output processing.

Rules are evaluated in list order. Within a single rule, class keys are merged in attrset key order (Nix attrset key order is alphabetical). Across multiple rules, later rules override earlier ones for the same path.

nest.rules = [
# Static config for all hosts
{
is = nest.host;
nixos = {
system.stateVersion = "25.11";
boot.loader.grub.enable = false;
};
}
# Dynamic config using select.node
{
is = nest.host;
nixos = { host, ... }: {
networking.hostName = host.name;
};
}
# Cross-node query via select.siblings
{
is = nest.lb;
nixos = { select, ... }:
let webs = select.siblings nest.web;
in {
services.haproxy.backends = map (w: w.addr) webs;
};
}
# Compound selector: host with admin child
{
is = [ nest.host (nest.has nest.admin) ];
nixos.security.sudo.wheelNeedsPassword = false;
}
# Rule synth: inject children
{
is = [ nest.host (nest.attrs { env = "prod"; }) ];
synth = { select, ... }: {
node.children =
map (u: { inherit (u) name sshKeys; is = [ nest.user nest.admin ]; })
(select nest.admin);
};
}
# User rule: fires on synthesized user children
{
is = nest.user;
user = { user, ... }: {
isNormalUser = true;
openssh.authorizedKeys.keys = user.sshKeys;
};
}
# Admin role adds wheel group
{
is = nest.admin;
user.extraGroups = [ "wheel" ];
}
];
Contribute Community Sponsor