Skip to content

Instantly share code, notes, and snippets.

@sini
Last active June 25, 2026 22:14
Show Gist options
  • Select an option

  • Save sini/58bab05ae3d3605de07edba94f7b3c7d to your computer and use it in GitHub Desktop.

Select an option

Save sini/58bab05ae3d3605de07edba94f7b3c7d to your computer and use it in GitHub Desktop.
Per-Aspect Settings in Den — a beginner-friendly (simplified English) guide to the host/aspect settings pattern

Per-Aspect Settings in Den — a beginner-friendly (simplified English) guide to the host/aspect settings pattern

Per-Aspect Settings in Den — A Beginner-Friendly Guide

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.


Part 0 — Some Basics First

If you already know these things, skip ahead. If not, reading this will make everything later much easier.

What are Nix and NixOS?

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.

What is an attribute set (attrset)?

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"; }

What are modules, options, and types?

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.

What are Den and an "aspect"?

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.

What is a "host"?

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.


What problem are we actually solving?

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 optimizationzen4 on one, server on 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.


The Four Moving Parts (the big picture)

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.


Part 1 — The Easy Way (the module-system pattern)

One complete example, start to finish

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.

Step 1 — Declaring settings on an aspect

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:

  • settings holds only option declarations, not config. Put only mkOption here, not value assignments. (If you genuinely need to ship a default config too, see the "module-shaped" form below.)
  • Leaving out default makes the setting "required". Above, device_id has no default, so any host that includes zfs-disk-single must provide it, or the build fails. This is a good thing — the type system forces you to remember "hey, tell me which disk."

Required vs. default — know the difference

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).

The "module-shaped" form (a bit advanced — skip on first read)

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).

Step 2 — The generator (you write this just once)

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.

First, some background: what does a Den entity schema look like?

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 in gen-algebra for validators).
  • den — the most important one! This is the whole framework registry. den.aspects is the tree of all aspects; den.classes holds the output classes (nixos, homeManager, ...); den.quirks holds extras. The generator reads den.aspects to find settings, and den.classes / den.quirks to 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.

One required setup step first (don't skip this!)

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.

The generator itself (the code)

What settingsType does, in one sentence:

Wherever .settings appears in the aspect tree, create a typed option at the same path under host.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 tree

What 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 (like includes, nixos, homeManager, your class names, and so on). If it's wrong, the generator will mistakenly create junk options like host.settings.<aspect>.nixos.
  • hasSettingsDeep — prunes empty branches, so options are created only along paths that actually have a settings somewhere.
  • 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, nodeModule calls itself for each branch of the tree — which is how it can handle a tree of any depth.

Putting it together — the whole file's skeleton

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 den in its arguments — that's how settingsType can see den.aspects. If your framework hands you the registry under a different name, use that.
  • settings is 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.

Step 3 — Setting values on a host

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.

Step 4 — Using the values inside the aspect

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 write cfg.optimization. This is the same idiom you'll see in real NixOS modules as cfg = config.services.foo.
  • Always read by the same path you declared. An aspect can read another aspect's settings (it's all one host.settings tree), 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.

Who wins? (Layering and priority)

When the same value is set in several places, which one wins? There are two levels:

Within one hosthost.settings is a module, so the module system's priority applies. Weakest to strongest:

  1. the aspect's mkOption default = (weakest);
  2. config the aspect sets itself (usually with lib.mkDefault);
  3. the value the host wrote directly in settings (this beats the ones above);
  4. lib.mkForce anywhere — 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)

Gotchas (things to watch out for)

  • Declarations are separate from config. A plain settings attrset should contain only mkOptions. If you catch yourself writing foo = "bar"; (an assignment) there, either turn it into mkOption { default = "bar"; }, or use the module-shaped form.

  • The or-default statix trap. In the generator we deliberately keep imports' = raw.imports or [ ] and config' = raw.config or { } under distinct names. Don't "tidy" these into inherit (raw) imports; — a tool called statix rewrites that in a way that drops the or [ ] default, and then the moment a plain settings block (with no imports key) 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 settings as 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 declared settings under. 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.

Checklist for adding a new setting

  1. In the aspect, add a new mkOption to its settings (create the block if there isn't one). Unless it truly differs per machine, give it a default.
  2. In the aspect's nixos / homeManager function, take host as an argument and read host.settings.<path>.<key> (give it the short name cfg).
  3. On each host that needs a non-default value, set settings.<path>.<key>.
  4. Build that host to confirm the type resolves and all required values are present.

Part 2 — Addendum: "first-class" settings (advanced)

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):

1. Settings now also state their "merge strategy"

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.

2. Overrides are keyed by scope name, separate from the entity

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".

3. The cascade is an explicit, traceable process

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

4. Policy is a final, strongest layer

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.

5. The aspect now receives settings as an argument (injection)

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).

6. Provenance — "where did this value come 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".

Part 1 vs. Part 2 at a glance

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)

Which one should you use, and when?

  • 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.


Glossary — what each word means

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! 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment