Skip to content

CSS configures a DOM tree.

Most developers know the basics of how CSS selectors works: you give elements classes, you write rules that target those classes, and the browser applies styling.

Nest applies the same model to Nix Configurations.

Web (CSS)Nest
HTML elementConfigurable Node (host, user, service, …)
CSS classTrait (gaming, devenv, lb, web)
class="web server"Tag a node with traits. is = [ web server ]
Stylesheet rule .web { … }Configure NixOS { is = web; nixos = …; }
DOM hierarchyInfrastructure attrset hierarchy
Inherited CSS propertiesNode attribute inheritance
:has(.admin)select (has admin)
Element sibling traversalselect.siblings web

Your attrset structure is the DOM. Everything outside nest.{trait,rules} is part of your DOM.

nest.prod.system = "x86_64-linux";
nest.prod.env = "prod";
nest.prod.lb = { is = [ nest.host nest.lb ]; addr = "10.0.0.1"; };
nest.prod.web = { is = [ nest.host nest.web ]; addr = "10.0.0.2"; };

lb and web both inherit system = "x86_64-linux" and env = "prod" from the prod namespace — no repetition. Moving a node to a different namespace changes what it inherits.

A trait is used to group nodes. A node can have many traits:

is = [ nest.host nest.web ] # two traits, like class="host web"

Traits form dependency DAGs. Declaring server.needs = [nginx ssh firewall] means any node with nest.server automatically gains those three traits — and any rules targeting nginx, ssh, or firewall fire on it:

nest.trait.server.needs = [ nest.nginx nest.ssh nest.firewall ];
nest.trait.web.needs = [ nest.server ];
# A node with is = [nest.host nest.web] gets:
# web → server → nginx, ssh, firewall (all automatically)

neededBy is the inverse: the trait declares what it attaches to, not the node:

nest.trait.monitoring.neededBy = nest.server;
# Every server node automatically gains monitoring — no node needs to opt in

Rules target nodes by using CSS selectors and contribute Dendritic Nix config fragments:

nest.rules = [
# Target all nodes with the nginx trait
{ is = nest.nginx;
nixos.services.nginx.enable = true; }
# Target lb nodes, access sibling web nodes via select
{ is = nest.lb;
nixos = { select, ... }:
let webs = select.siblings nest.web;
in { services.haproxy.backends = map (w: w.addr) webs; }; }
];

Multiple rules can target the same node — their nixos contributions are collected as a list and passed together to nixosSystem, just like multiple CSS rules applying to one element. The NixOS module system merges them with full option semantics.

In CSS, .prod .web selects web elements inside prod. In Nest, siblings respect namespace boundaries:

select.siblings nest.web # returns only prod webs — not staging webs

The namespace boundary scopes the sibling query

Prod and staging are separate namespace containers. Sibling queries from prod nodes never cross into staging. This is intentional — your topology structure is semantic.

Every CSS selector concept maps to a Nest selector:

"*" # star: any node
"#lb-prod" # id: node named lb-prod
".lb" # trait: all nodes having trait
"[env=prod]" # attr: attribute equality
"[system]" # attrExists: attribute presence
".web, .lb" # or: either trait
":not(.staging)" # not: negation
":has(.admin)" # has: child with trait
":within(.prod)" # within: ancestor with trait
"prod > web" # child combinator
"prod web" # descendant combinator (CSS space)

Programmatic selectors are also available as constructor functions:

nest.has nest.admin
nest.within nest.prod
nest.not nest.staging
nest.attrs { env = "prod"; }
nest.when ({ host, ... }: host.port > 1024)
[ nest.host nest.web ] # list = AND compound
Contribute Community Sponsor