Per-Aspect Settings in Den — a beginner-friendly (simplified English) guide to the host/aspect settings pattern
Hi! 👋 This guide teaches you a really useful pattern used in a Nix framework called Den. Don't worry if you're new to Nix — we'll explain everything slowly, in plain language, with examples.
Who is this for? Anyone learning about Nix/NixOS who knows a little but isn't an expert yet. If a word looks unfamiliar, don't panic — there's a glossary at the very bottom where every technical term is explained.
A quick note about the code: all the code in this guide stays in its normal
form (programming is written this way), but the # comments inside the code
explain what each part does in plain English, so you can follow along.
This guide is a gentler version of the full reference. If you ever want the dense, complete version, see host-aspect-settings.md.
If you already know these things, skip ahead. If not, reading this will make everything later much easier.
Imagine you want to set up your computer a certain way — which programs to install, which settings to use, and so on. Normally you do all of this manually, one thing at a time, and later it's hard to remember what you changed.
Nix is a tool where you write your whole system down in a text file — like a recipe. Nix then reads that recipe and builds exactly that system. The benefit: your entire setup lives in one file, stays the same every time, and can be shared with others.
This style is called declarative — you describe what you want (the result), not how to do it step-by-step. NixOS is a complete Linux operating system built on top of Nix.
The most common way to hold data in Nix is an attribute set, or attrset for short. If you've seen a Python dictionary or a JavaScript object, it's the same idea — a name (key) paired with a value:
{
name = "cortex";
cores = 16;
enabled = true;
}attrsets can live inside one another (nested), and we use a dot (.) to
reach the value inside. These two are exactly the same thing:
# Way 1: nested
{ disk = { device = "/dev/sda"; }; }
# Way 2: dotted (shorthand) — means exactly the same
{ disk.device = "/dev/sda"; }NixOS uses something called the module system. Think of it like this:
- An option is an empty "slot" that can be filled with a value — like a
field in a form. Every option has a type — what kind of value is allowed
(a number, true/false, text, a list, and so on). We create an option with
lib.mkOption. - A module is just an attrset that either creates new options
(
options = {...}) or fills in values for existing options (config = {...}).
A tiny example:
{
options = {
# Create an option called "workers" that only accepts a number,
# and defaults to 4 if no value is given.
workers = lib.mkOption {
type = lib.types.int; # type = whole number (integer)
default = 4; # default value
description = "How many workers to run";
};
};
}The best part of the module system: if you put in the wrong type of value (say, text where a number is expected), you get an error at build time, before the program ever runs. Mistakes get caught early.
Den is a framework that makes large NixOS setups easier to manage — especially when you have many computers (a "fleet").
The most important idea in Den is the aspect. An aspect is a small, reusable piece of configuration. Think of it like a pizza topping 🍕 — each pizza (computer) can have different toppings (aspects):
- one aspect sets up "gaming",
- one aspect installs a disk layout called ZFS,
- one aspect picks a particular kernel.
When you build a computer, you just includes the aspects you need, and they
all wire themselves in.
In Den, a host means one computer/machine — like your laptop, or a server. Each host picks the aspects it needs.
Now we're ready! The whole guide rests on these ideas.
Aspects are shared across many computers. Most of an aspect is the same everywhere. But a few values are different on each machine. For example:
- a ZFS disk aspect needs the disk address (device id) — different on every box;
- a kernel aspect needs the CPU optimization —
zen4on one,serveron another; - a BGP aspect (a networking thing) needs this machine's AS number.
So the question is: how do we put these different values into an aspect without copying the aspect for every machine?
The wrong ways (don't do these):
- hard-code the value into the aspect → now the aspect can't be reused;
- add a separate option on the host for every aspect → the host's settings grow out of control;
- read the value from some global place → you lose both typing and tidiness.
The right way — the "settings" pattern: each aspect declares a few typed "knobs" (adjustable values) for itself. Then the host fills those knobs in. That's the whole idea, and it's what we'll learn next.
This pattern works in 4 parts. Look at the whole picture first — don't worry, we'll explain each part separately below:
┌─ 1. The aspect DECLARES its settings ──────────────────────────────────┐
│ den.aspects.core.system.linux-kernel = { │
│ settings = { optimization = lib.mkOption { ... }; }; │
│ }; │
└────────────────────────────────────────────────────────────────────────┘
│ (discovered automatically)
▼
┌─ 2. The host schema GENERATES a typed namespace by itself ─────────────┐
│ your host schema scans den.aspects and creates a typed option: │
│ host.settings.core.system.linux-kernel.optimization │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ 3. The host SETS a value ─────────────────────────────────────────────┐
│ den.hosts.x86_64-linux.cortex.settings = { │
│ core.system.linux-kernel.optimization = "zen4"; │
│ }; │
└────────────────────────────────────────────────────────────────────────┘
│
▼
┌─ 4. The aspect CONSUMES (uses) that value ─────────────────────────────┐
│ nixos = { host, pkgs, ... }: │
│ let cfg = host.settings.core.system.linux-kernel; in { ... }; │
└────────────────────────────────────────────────────────────────────────┘
Here's the key thing to understand: parts 1 and 4 live inside the aspect, part 3 lives inside the host, and part 2 happens automatically. So adding a new setting to an aspect doesn't mean touching the host schema. You write part 2 (the generator) exactly once, ever.
The smallest aspect that shows the whole pattern is one that picks a kernel. Here, declaring (part 1) and using (part 4) both live in the same file:
{ lib, ... }:
{
den.aspects.core.system.linux-kernel = {
# (1) DECLARE: two typed knobs, both with defaults.
settings = {
channel = lib.mkOption {
type = lib.types.enum [ "lts" "latest" ]; # only one of these two
default = "latest";
description = "Which release channel of the CachyOS kernel";
};
optimization = lib.mkOption {
type = lib.types.enum [ "server" "zen4" "x86_64-v4" ];
default = "server";
description = "Which CPU the kernel is optimized for";
};
};
# (4) CONSUME: this module receives `host` as an argument, and reads its
# own settings back from host.settings.<this aspect's path>.
nixos =
{ host, pkgs, ... }:
let
# cfg = a short name, so we don't write the full path again and again
cfg = host.settings.core.system.linux-kernel;
kernelName =
if cfg.optimization == "server" then
"linuxPackages-cachyos-server-lto"
else
"linuxPackages-cachyos-${cfg.channel}-lto-${cfg.optimization}";
in
{
boot.kernelPackages = pkgs.cachyosKernels.${kernelName};
};
};
}And a host fills in just the one knob it cares about. It never touches
channel, so that stays at its default ("latest"):
den.hosts.x86_64-linux.cortex.settings = {
core.system.linux-kernel.optimization = "zen4";
};That's it — that's the whole story. The host didn't import the aspect's type,
didn't name any special argument, didn't wire anything up. It just wrote
settings.core.system.linux-kernel.optimization, and the value arrived inside
the aspect by the same path. This "magic" is thanks to part 2 — which we'll see
next.
Add a settings attribute to your aspect, right next to includes, nixos,
and homeManager. Inside it are just lib.mkOption declarations:
den.aspects.disk.zfs-disk-single = {
includes = [ den.aspects.disk.zfs-disk-single.root ];
settings = {
device_id = lib.mkOption {
type = lib.types.str; # str = text (a string)
description = "Path to the disk for the ZFS pool (e.g. /dev/disk/by-id/nvme-...)";
};
};
nixos = { config, host, ... }: {
# ... this uses host.settings.disk.zfs-disk-single.device_id
};
};Two important rules:
settingsholds only option declarations, not config. Put onlymkOptionhere, not value assignments. (If you genuinely need to ship a default config too, see the "module-shaped" form below.)- Leaving out
defaultmakes the setting "required". Above,device_idhas no default, so any host that includeszfs-disk-singlemust provide it, or the build fails. This is a good thing — the type system forces you to remember "hey, tell me which disk."
| Form | Meaning |
|---|---|
mkOption { type = ...; } (no default) |
Required. The host must set it; missing → error. |
mkOption { type = ...; default = x; } |
Optional. If the host says nothing, x is used automatically. |
Tip: if a value has a sensible "same for everyone" default, give it one. Reserve "required" for things that truly differ per machine (like a disk id or AS number).
Usually settings is just an attrset of options. But if you need to, you can
make it a full module with separate imports / config / options — then an
aspect can both declare options and set some default config:
settings = {
options = {
replicas = lib.mkOption { type = lib.types.int; default = 1; };
};
config = {
# a more complex default than `default =` can express
replicas = lib.mkDefault 3;
};
imports = [ ./extra-settings-module.nix ];
};The generator (part 2) turns a plain attrset into this same shape automatically,
so only reach for this longer form when you need config or imports. Because
the module system runs the settings, priorities like lib.mkDefault /
lib.mkForce work here too (see "Who wins?" below).
This is the "magic" part that makes everything above wire up automatically. It's
a piece of your host schema that gathers up "all the aspects that declared
settings" into one typed host.settings tree. You write it once, and after
that every aspect's settings show up by themselves.
If you're new to Den, this is the one bit of background you need before the generator makes sense.
Den builds every kind of thing (entity) — host, environment, user,
group — from a schema declared under den.schema.<name>. The key that matters
here is den.schema.<name>.imports: it's a list of modules, and these
modules' options become that entity's options. Here's a complete, minimal
example — the group entity (trimmed a little) — so the skeleton is clear:
{ lib, ... }:
let
inherit (lib) mkOption types;
in
{
den.schema.group.imports = [
(_: {
options = {
gid = mkOption {
type = types.nullOr types.int; # either a number, or nothing (null)
default = null;
description = "POSIX group ID";
};
# ... more options ...
};
})
];
}That's the whole skeleton: a file that is itself a module, and inside it a
den.schema.<name>.imports list. Every entity in Den — host included — is
built exactly this way; the host schema is just a bigger version of this.
The file's arguments (the function's inputs) are your tools:
lib— Nix's standard library (mkOption,types, etc. come from here).inputs— your flake's inputs (e.g. pulling ingen-algebrafor validators).den— the most important one! This is the whole framework registry.den.aspectsis the tree of all aspects;den.classesholds the output classes (nixos,homeManager, ...);den.quirksholds extras. The generator readsden.aspectsto find settings, andden.classes/den.quirksto know which keys are framework machinery (not real child aspects).
So the plan for the host is: in the file's let block, build a settingsType
from den.aspects, then attach it as one more option (settings) inside
den.schema.host.imports. The next two sections show each half.
The generator below uses a helper called skipKey to decide which keys are
"real child aspects" and which are framework machinery to ignore. For that to
work, you have to tell Den that settings is a special key, not an ordinary
aspect. You do this once, in any module (for example a defaults.nix):
den.reservedKeys = [ "settings" ];If you forget this line, you get a scary error like this when you look at a host's settings:
error: stack overflow; max-call-depth exceeded
at .../host.nix: ... hasSettingsDeep ...
Here's why, in plain terms: without that line, the generator doesn't know
settings is special, so it tries to "look inside" your settings the same way it
looks inside aspects. It walks into each mkOption, then into the option's
type, and a type in Nix points back at itself in a loop — so the program goes
round and round until it runs out of room. Adding
den.reservedKeys = [ "settings" ]; tells the generator to leave the settings
block alone, and the error disappears. So: add that one line before anything
else.
What settingsType does, in one sentence:
Wherever
.settingsappears in the aspect tree, create a typed option at the same path underhost.settings. If a place has its own settings and its children have settings, merge both. Skip the framework's internal keys.
Here's the full code, with English comments. In nix-config it lives in
modules/den/schema/host.nix. (You don't need to understand every line on the
first read — below the code we explain all three helpers in plain language.)
settingsType =
let
# Keys that are NOT children of an aspect: structural keys (includes,
# nixos, ...), plus your framework's class names and quirk/extension keys.
# Adapt these three sources to your own framework.
inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet;
classKeys = den.classes or { };
quirkKeys = den.quirks or { };
skipKey = k: structuralKeysSet ? ${k} || classKeys ? ${k} || quirkKeys ? ${k};
# settings can be a plain options-attrset ({ foo = mkOption {...}; })
# OR module-shaped ({ imports; config; options; }). Normalize both to one shape.
reshapeSettings =
raw:
let
# Distinct names on purpose — see the statix note under "Gotchas".
imports' = raw.imports or [ ];
config' = raw.config or { };
in
{
imports = imports';
config = config';
options = removeAttrs raw [ "imports" "config" ];
};
# True if this place, or anything below it, declares settings.
hasSettingsDeep =
node:
builtins.isAttrs node
&& (
(node ? settings)
|| lib.any (k: !(skipKey k) && hasSettingsDeep (node.${k} or null)) (builtins.attrNames node)
);
# Build the submodule for one place (node) in the aspect tree, mirroring
# the tree. Merge this place's own settings with its children's settings.
nodeModule =
node:
let
ownSettings =
if node ? settings then
reshapeSettings node.settings
else
{ imports = [ ]; config = { }; options = { }; };
settingChildren = lib.filterAttrs (
k: v: !(skipKey k) && builtins.isAttrs v && hasSettingsDeep v
) node;
childOptions = lib.mapAttrs (
name: child:
mkOption {
type = types.submodule (nodeModule child); # recursion (calling itself)
default = { };
description = "Settings under ${name}";
}
) settingChildren;
# Distinct names again — keeps statix from dropping the `or` default.
ownImports = ownSettings.imports or [ ];
ownConfig = ownSettings.config or { };
in
{
imports = ownImports;
config = ownConfig;
options = (ownSettings.options or { }) // childOptions;
};
in
types.submodule (nodeModule (den.aspects or { })); # start from the whole aspect treeWhat each helper does, in plain words:
skipKey— the only part that differs between frameworks. It returns "true" for every key that is not a child of an aspect (likeincludes,nixos,homeManager, your class names, and so on). If it's wrong, the generator will mistakenly create junk options likehost.settings.<aspect>.nixos.hasSettingsDeep— prunes empty branches, so options are created only along paths that actually have asettingssomewhere.nodeModule— the real recursion (walking down the tree, mirroring it). The key subtlety: one place can have both its own settings and children with settings. It merges the two, so the parent aspect's knobs and the children's knobs live together under one path.
What is recursion? When a function calls itself, that's recursion. Here,
nodeModulecalls itself for each branch of the tree — which is how it can handle a tree of any depth.
Now we put both halves in one file. The generator lives in the let; the
settings option is attached inside den.schema.host.imports, next to the
host's other options. Here's the whole host schema as a skeleton (the other
options like channel are ordinary mkOptions you add as needed):
{ lib, inputs, den, self, ... }: # ← note `den` in the arguments
let
inherit (lib) mkOption types;
# ... other helpers: interfaceType, channel definitions, etc. ...
settingsType =
let
# skipKey / reshapeSettings / hasSettingsDeep / nodeModule
# (the generator from the previous section)
# ...
in
types.submodule (nodeModule (den.aspects or { })); # ← reads the aspect tree
in
{
den.schema.host.isEntity = true;
den.schema.host.imports = [
(
{ config, ... }:
{
options = {
channel = mkOption { /* ... */ };
environment = mkOption { /* ... */ };
# ... the rest of the host's options ...
# Here is the auto-generated settings namespace:
settings =
mkOption {
type = settingsType;
default = { };
description = "Per-aspect typed settings";
}
# Keep settings out of the entity's identity (see Gotchas).
// {
identity = false;
};
};
# config = { ... }; # computed defaults for other options, if any
}
)
];
}Just three things to notice on a first read:
- The whole file is one module function with
denin its arguments — that's howsettingsTypecan seeden.aspects. If your framework hands you the registry under a different name, use that. settingsis just one option among many on the host. Nothing else in the schema needs to know it exists — aspects and hosts wire themselves through it automatically.- The
// { identity = false; }bit is specific to nix-config (its entity identity hashing). Drop it if your framework has no such concept.
That's the entire host-side investment: one option, backed by one ~80-line let
block, written once. After that, any aspect that declares settings shows up
under host.settings with no further schema edits.
In a host's definition, write a settings attribute. Address values by the
aspect's path. Remember, nested and dotted forms are the same thing:
den.hosts.x86_64-linux.cortex = {
channel = "nixpkgs-master";
environment = "dev";
# ...
settings = {
disk.zfs-disk-single.device_id =
"/dev/disk/by-id/nvme-Samsung_SSD_990_PRO_4TB_…";
core.system.linux-kernel.optimization = "zen4";
core.impermanence = {
wipeRootOnBoot = true;
wipeHomeOnBoot = false;
};
};
};Every value is typed, so a typo in the path or a wrong-typed value gives you an
error at build time, pointing right at that option — it won't be silently
ignored. A host that's happy with all the defaults writes no settings at all.
An aspect's delivery modules (nixos, homeManager) are actually functions
that receive the fully-resolved host as an argument. Read your settings from
host.settings.<your path>:
nixos =
{ config, host, ... }:
let
disk-device = host.settings.disk.zfs-disk-single.device_id;
in
{
disko.devices.disk.disk0.device = disk-device;
# ...
};Two habits that make this easier to read:
- Give your settings chunk a short name
cfg(cfg = host.settings.core.system.linux-kernel;), then writecfg.optimization. This is the same idiom you'll see in real NixOS modules ascfg = config.services.foo. - Always read by the same path you declared. An aspect can read another
aspect's settings (it's all one
host.settingstree), but do this rarely — it ties the two aspects together.
host is the whole host entity, so you also get host.system,
host.environment, host.networking, and so on from it. Settings are just one
branch of it.
When the same value is set in several places, which one wins? There are two levels:
Within one host — host.settings is a module, so the module system's
priority applies. Weakest to strongest:
- the aspect's
mkOptiondefault =(weakest); - config the aspect sets itself (usually with
lib.mkDefault); - the value the host wrote directly in
settings(this beats the ones above); lib.mkForceanywhere — this beats everything (strongest).
Across the whole fleet — there's also a bigger cascade:
root → environment → host. So an environment can set a default for all its
hosts, and any host can override it. (The advanced version of this is in Part 2.)
root < environment < host (later one wins)
-
Declarations are separate from config. A plain
settingsattrset should contain onlymkOptions. If you catch yourself writingfoo = "bar";(an assignment) there, either turn it intomkOption { default = "bar"; }, or use the module-shaped form. -
The
or-default statix trap. In the generator we deliberately keepimports' = raw.imports or [ ]andconfig' = raw.config or { }under distinct names. Don't "tidy" these intoinherit (raw) imports;— a tool called statix rewrites that in a way that drops theor [ ]default, and then the moment a plain settings block (with noimportskey) comes along, you get an error. (If you run statix through a formatter, this rewrite can sneak in on save — turn that rule off for this file.) -
Keep settings out of entity identity. If your framework hashes entities by their values, mark
settingsas identity-excluded, so two hosts that differ only in settings aren't counted as different. In nix-config that's// { identity = false; }on the option. -
The path must match exactly. The path an aspect reads with
host.settings.<path>must be the same path it declaredsettingsunder. There's no shortcut or alias. -
A missing required setting gives a clear error. Including an aspect but not giving its required setting is a straightforward error. That's by design — the "forgot to say which disk" mistake is caught before anything runs.
- In the aspect, add a new
mkOptionto itssettings(create the block if there isn't one). Unless it truly differs per machine, give it adefault. - In the aspect's
nixos/homeManagerfunction, takehostas an argument and readhost.settings.<path>.<key>(give it the short namecfg). - On each host that needs a non-default value, set
settings.<path>.<key>. - Build that host to confirm the type resolves and all required values are present.
Note: This part is a bit advanced. If you're just starting out, get comfortable with Part 1 first — you can read this later. Here we're showing where this pattern is heading.
In Part 1 we built this pattern "by hand" on top of the NixOS module system:
settings were mkOptions, and the aspect read host.settings directly. That's
what's used today, and it works great.
The version coming with den-hoag takes the same idea and makes it more powerful through a library (gen-aspects). The main differences (each explained in a line or two):
In Part 1, when the same value appears in several layers, how they combine
depended on the type. Here, each field says clearly how to combine — replace
(the new value replaces the old), append (join the lists), or recursive
(deep-merge the attrsets):
settings = {
performance.workers = { default = 4; }; # replace (default)
security.allowed-origins = { default = [ ]; merge = "append"; }; # keep adding
locations = { default = { }; merge = "recursive"; }; # deep merge
};This is the biggest convenience over Part 1, because the merge behavior is now visible instead of hidden.
Instead of writing values in each host, all the overrides live in one
scopeSettings, keyed by scope name (env:<name> / host:<name>):
config.scopeSettings = {
"env:prod" = { nginx.performance.workers = 16; };
"host:prod-web-1" = { nginx.performance.workers = 32; };
};This separates "what an entity is" from "what its settings are".
Combining values across layers is done by a real pipeline (not left to module-merge): first extract every aspect's settings schema → build a scope graph (env, host nodes) → collect layers from most-specific to least-specific → then merge using each field's strategy. For example:
aspect default: nginx.performance.workers = 4
env:prod: nginx.performance.workers = 16
host:prod-web-1: nginx.performance.workers = 32 ← this wins
A policy engine can apply rules like "hosts in production get hardening". The settings those rules produce join as the very last layer — so policy beats both environment and host.
In Part 1 the aspect read host.settings.<full path>. Here the aspect's code
gets a settings argument directly, already resolved:
nixos = { settings, host, lib, ... }: {
services.nginx.config = ''
worker_processes ${toString settings.nginx.performance.workers};
'';
};A construct (injectAspectSettings) injects the cascade's final values into
this code for each (host, aspect) pair — along with a contract (a check that the
value has the right shape) and provenance (where the value came from).
The system remembers, for each field, which layer its final value came from (default / env / host / policy). So you can ask "why is workers 32?" and get the answer "because of the host".
| Concern | Part 1 (module system) | Part 2 (gen-aspects, den-hoag) |
|---|---|---|
| Declaration | mkOption (full NixOS types) |
schema leaf { default; merge?; } |
| Merge behavior | hidden in type + module priority | explicit per field: replace / append / recursive |
| Where overrides go | directly on the host | scopeSettings, keyed by scope name |
| Cascade | module priority + a side graph | scope graph + traverse + traced fold |
| Policy layer | none (or a workaround) | yes, as the final layer |
| How the aspect reads | host.settings.<full path> |
settings.<leaf> (injected) |
| Provenance | not tracked | per field (which layer won) |
- Choose Part 1 when you want the simplest thing, full NixOS type-checking, and you're fine with module-merge behavior. It's a great default, and the generator is only ~80 lines.
- Choose Part 2 when you want a clear merge strategy per field, a cascade with a policy layer, or to know "which value came from where" (provenance). That's the direction den-hoag is going.
The two can also coexist: keep Part 1 for machine-specific values (like a disk id), and adopt Part 2's cascade for fleet-wide feature settings.
| Term | Plain meaning |
|---|---|
| Nix | A tool to write your whole system as a "recipe" in one text file. |
| NixOS | A complete Linux operating system built on Nix. |
| Declarative | Saying "what you want", not "how to do it". Result-focused. |
| Attrset | attribute set — name→value pairs (like a Python dict / JS object). |
| Module | An attrset that creates options or fills in their values. |
| Option | An empty "slot" filled with a value; made with lib.mkOption. |
| Type | What kind of value an option allows (int, str, bool, enum...). |
| Default | The value used automatically if none is given. |
| Required | A setting with no default — the host must provide it. |
| Den | A framework for managing large NixOS setups (many computers). |
| Aspect | A small, reusable config piece (like a pizza topping). |
| Host | One computer/machine. |
| Settings | An aspect's typed "knobs" that the host fills in. |
| Schema | The shape of an entity (host/user...) — which options it has. |
| Generator | The code that builds the host.settings tree from aspects' settings. |
| Recursion | When a function calls itself (here, to walk down a tree). |
| Cascade | Values combining across layers (default → env → host). |
| Provenance | A value's "source" — where it came from. |
lib.mkDefault |
A weak value (easily overridden). |
lib.mkForce |
The strongest value (beats everything). |
That's it! If you've followed this far, you can use this pattern in your own Den config. 🎉 Start with Part 1 — it covers 90% of cases. Good luck! 🚀