Integrating third-party NixOS-style system builders—such as Finix—into a Den flake workspace requires understanding how Den manages module collection, builder invocation, and output attribute paths.
This tutorial provides a complete architectural guide and three practical integration patterns for wiring Finix hosts into a Den-managed fleet.
Revision note. The first version of this guide was not runnable end to end. Its
finixSystemcalls omitted the requiredlibargument, it assumed Finix declares thenixpkgs.*options Den emits, and it activated host aspects through a key Den ignores. Every code block below has now been evaluated against denc7ef3f1and finix78fd549; §6 builds a realfinix-system.drv. The three corrections are called out inline as Correction 1/2/3.
Finix is a minimal Linux distribution derived from NixOS base modules, designed for low-resource footprint or container environments. Key differences from standard NixOS include:
- Init System: Uses
finit(Fast Init) instead ofsystemd. - Device & Seat Management: Ships
mdevdin place ofeudevandseatdin place ofelogind(compatibilityservices/udevandservices/elogindmodules still exist in the tree). - Flake Entrypoint: Uses
inputs.finix.lib.finixSystem { … }to evaluate configurations instead ofnixpkgs.lib.nixosSystem.
# finix/flake.nix
lib.finixSystem =
{ lib ? null, specialArgs ? { }, modules ? [ ], ... }:
let
config = lib.evalModules {
class = "nixos";
specialArgs = lib.recursiveUpdate { modules = self.nixosModules; } specialArgs;
modules = [ self.nixosModules.default ] ++ modules;
};
in
config // { inherit (config._module.args) pkgs; inherit lib; };Three consequences drive everything below:
libdefaults tonulland is dereferenced immediately. Omitting it fails witherror: expected a set but found null: null, pointing atfinix/flake.nix— not at your configuration. Always passlib = inputs.nixpkgs.lib;.- Every other argument is swallowed by
....pkgs,modulesPath,systemand friends are accepted silently and ignored.finixSystemtakes exactlylib,specialArgs, andmodules. pkgscomes from inside the module system, via Finix's requirednixpkgs.pkgsoption — not from a builder argument.
In Den, system configuration evaluation and flake output generation are managed through three decoupled concepts:
- Classes (
den.classes.<name>): Named payload buckets (e.g.nixos,darwin,homeManager,droid,k8s-manifests,finix). When an aspect defines a top-level key matching a registered class name (e.g.finix = { ... };), Den collects those modules for target entities during policy resolution. - Instantiate Thunks (
instantiate = { modules, ... }: ...): A function that receives all Den-collected class modules for an entity and invokes the target builder (inputs.finix.lib.finixSystem). - Flake Output Binding (
intoAttr = [ "nixosConfigurations" host.name ]): An attribute path specification telling Den where on the output flake attribute set to place the evaluated system object.
For each host in den.hosts, Den re-walks that host's scope subtree and collects every module emitted into the bucket named by host.class. It then calls:
host.instantiate {
modules = <collected class modules> ++ [
{ nixpkgs.hostPlatform = lib.mkDefault host.system; }
];
}The trailing nixpkgs.hostPlatform module is appended whenever the host spec has no pkgs attribute (nix/lib/aspects/fx/resolve.nix). host.mainModule is the fallback used only when the subtree walk yields no modules at all — it is not normally what gets passed.
Den then places the resulting evaluation object with lib.setAttrByPath ([ "flake" ] ++ host.intoAttr) (nix/lib/aspects/fx/edges/instantiate.nix), i.e. at flake.<seg1>.<seg2>….
By default, Den ships with pre-registered defaults in den/nix/lib/entities/host.nix:
host.class |
Default instantiate Builder |
Default intoAttr Output Path |
|---|---|---|
nixos |
inputs.nixpkgs.lib.nixosSystem |
[ "nixosConfigurations" host.name ] |
darwin |
inputs.darwin.lib.darwinSystem |
[ "darwinConfigurations" host.name ] |
systemManager |
inputs.system-manager.lib.makeSystemConfig |
[ "systemConfigs" host.name ] |
(See reference/output.mdx ["Build pipeline"] and reference/schema.mdx in the Den docs).
-
The Builder is Just a Function: From Den's perspective,
inputs.nixpkgs.lib.nixosSystemandinputs.finix.lib.finixSystemhave the same functional contract: takemodules(plus builder-specific args), and return an evaluated configuration attrset. Swapping builders is a plain option override — but the builders' argument sets differ, and Finix's is unusually small (see §1). -
Output Placement is Data:
intoAttris a simple list of path segments on the flake. Placed outputs appear undernixosConfigurationsorfinixConfigurationsautomatically. -
⚠️ Edge Case 1 — Evaluation Crash on Unknown Class: DefaultinstantiateandintoAttroption implementations perform a lookup indexed byconfig.class(.${config.class}). If a host setsclass = "finix"without supplying explicit overrides for both, evaluation crashes withattribute 'finix' missingatnix/lib/entities/host.nix:120. This is not lazy — it fires onbuiltins.attrNamesof the flake, becauseden.policies.system-to-os-outputsreadshost.intoAttrfor every host. Supplying explicit values via per-host options (Option A) orden.schema.host.imports(Option B) bypasses the lookup completely. -
⚠️ Edge Case 2 — Class Bucket Isolation & Aspect Routing: Den collects the class bucket matchinghost.class. If existing aspects write module config undernixos = { ... };and you declareclass = "finix", thosenixosblocks are ignored for that host by default. Remedy: register a route policy forwardingnixosclass content intofinix(shown in Option B). -
⚠️ Edge Case 3 — Finix does not declare thenixpkgsoptions Den emits (Correction 2). Finix'smodules/nixpkgs/default.nixdeclares exactly one option:options.nixpkgs.pkgs = lib.mkOption { type = lib.types.pkgs; }; # required, no default
There is no
nixpkgs.hostPlatformand nonixpkgs.config. Two Den behaviours collide with that:- Den appends
{ nixpkgs.hostPlatform = lib.mkDefault host.system; }(§2). - Den's always-on
unfree-predicateandinsecure-predicatedefault batteries import a module into${host.class}that setsconfig.nixpkgs.config.*. AmkIf falsedefinition still needs the option to exist, so this fires even when you use neither battery.
Symptom:
The option `nixpkgs.config' does not exist. … Did you mean `nixpkgs.pkgs'?— attributed to<class>@insecure-predicate/os. This affects Option A and Option B equally; the class name in the error changes, the failure does not. The fix is a three-line shim that declares both options and derivesnixpkgs.pkgsfrom them (below). - Den appends
-
⚠️ Edge Case 4 — Host aspects are not activated byden.hosts.….includes(Correction 3). A host's aspect tree isden.aspects.<hostName>plusden.schema.host.includes(nix/lib/resolve-entity.nix). Host submodules are freeform, so anincludeskey written directly onden.hosts.<system>.<name>is accepted and silently dropped. Useden.aspects.<hostName>.includes = [ … ]instead. -
Declaration Precedence &
mkForce: Plain option declarations overridemkOptiondefaults andmkDefaultblocks (such as the channel-awareinstantiateoverride inmodules/den/schema/host.nix).instantiateistypes.raw, whose merge accepts exactly one definition —lib.mkForceis required only when overriding another explicit plain assignment.
All three patterns need the nixpkgs shim from Edge Case 3. Define it once:
# modules/den/batteries/finix-nixpkgs-shim.nix
{ lib, inputs, ... }:
{
# Declares the two options Den emits but Finix does not, and derives Finix's
# required `nixpkgs.pkgs` from them. Gated on host.class so nixos/darwin hosts
# are untouched — change the guard to match whichever class you build through
# finixSystem (Option A uses "nixos").
den.default.includes = [
(
{ host, ... }:
{
name = "finix/nixpkgs-shim";
}
// lib.optionalAttrs ((host.class or null) == "finix") {
finix =
{ config, ... }:
{
options.nixpkgs = {
hostPlatform = lib.mkOption { type = lib.types.str; };
config = lib.mkOption {
type = lib.types.attrs;
default = { };
};
};
config.nixpkgs.pkgs = import inputs.nixpkgs {
system = config.nixpkgs.hostPlatform;
inherit (config.nixpkgs) config;
};
};
}
)
];
}Since Finix accepts standard NixOS modules, the simplest integration keeps class = "nixos" intact—allowing all existing nixos-class aspects and batteries to keep working—and swaps only the builder thunk on specific Finix hosts:
# modules/den.nix (or inside den.hosts)
{
den.hosts.x86_64-linux.finix-box = {
# class stays "nixos" (default), intoAttr stays [ "nixosConfigurations" "finix-box" ] (default).
# Only the builder thunk is overridden:
instantiate =
{ modules, ... }:
inputs.finix.lib.finixSystem {
inherit modules;
# Correction 1: `lib` defaults to null in finixSystem and is used as
# `lib.evalModules`. Omitting it fails with
# "expected a set but found null: null".
lib = inputs.nixpkgs.lib;
# Expose flake inputs to finix/NixOS modules:
specialArgs = { inherit inputs; };
};
};
# Correction 3: aspect activation lives on den.aspects.<hostName>, NOT on the
# host entity. `den.hosts.….includes` is a freeform key and is silently dropped.
den.aspects.finix-box.includes = [ den.aspects.base ];
}Note the two arguments that are gone versus the original guide: pkgs and modulesPath. Both were swallowed by finixSystem's ... and did nothing; inputs.finix.modulesPath does not exist as a flake output at all (Finix exports nixosModules, lib, formatter). Package set selection happens through the nixpkgs.pkgs shim instead.
Result: .#nixosConfigurations.finix-box is evaluated using inputs.finix.lib.finixSystem instead of lib.nixosSystem, while consuming the standard nixos class modules collected from the host's aspects. Retarget the shim's guard to (host.class or null) == "nixos" and its class key to nixos for this option.
- Builder Arguments: Den only supplies
modules. Everything else is owned by the builder — for Finix that meanslibandspecialArgs, nothing more. Flakeinputsare in scope wherever the module defininginstantiateis evaluated. hostPlatformHandling: Den appends{ nixpkgs.hostPlatform = lib.mkDefault host.system; }for hosts without an explicitpkgsattribute. Finix does not declare that option — the shim above supplies it, and reuses it to instantiate the package set.- Fleet-Wide Variant: If every
nixos-class host should evaluate through Finix, apply the override at the schema level instead of per-host:den.schema.host.imports = [ ( { config, ... }: lib.mkIf (config.class == "nixos") { instantiate = { modules, ... }: inputs.finix.lib.finixSystem { inherit modules; lib = inputs.nixpkgs.lib; specialArgs = { inherit inputs; }; }; } ) ];
- Module Import Variant: If you only need Finix's base modules but standard
nixosSystemoutput is acceptable, import those modules directly into your aspects without swapping the builder thunk.
If you operate a fleet of Finix machines and want the builder and output namespace to be a first-class property of the host (class = "finix"), register the class and provide class-scoped instantiate/intoAttr defaults via a schema import.
This pattern mirrors the structure of the Nix-on-Droid battery in nix-on-droid.nix, which defines class = "droid" with inputs.nix-on-droid.lib.nixOnDroidConfiguration and intoAttr = [ "nixOnDroidConfigurations" config.name ].
# modules/den/batteries/finix.nix
{
den,
lib,
inputs,
...
}:
{
# 1. Register 'finix' in Den's class registry so aspects can write under finix = { ... }
den.classes.finix.description = "finix (finix-community) system modules";
# 2. Class-scoped instantiation schema import.
# lib.mkIf (config.class == "finix") leaves nixos/darwin hosts untouched.
den.schema.host.imports = [
(
{ config, ... }:
lib.mkIf (config.class == "finix") {
instantiate =
{ modules, ... }:
inputs.finix.lib.finixSystem {
inherit modules;
# Correction 1 — see §1.
lib = inputs.nixpkgs.lib;
specialArgs = { inherit inputs; };
};
# Direct output placement. Use "nixosConfigurations" to sit alongside
# NixOS hosts, or a distinct namespace as here:
intoAttr = [
"finixConfigurations"
config.name
];
}
)
];
# 3. Forward existing 'nixos' aspect blocks into 'finix' class for finix hosts
den.policies.nixos-to-finix =
{ host, ... }:
lib.optional ((host.class or null) == "finix") (
den.lib.policy.route {
fromClass = "nixos";
intoClass = "finix";
path = [ ]; # top-level merge
}
);
# Register policy in global default scope so it fires in all scopes where host is bound
den.default.includes = [ den.policies.nixos-to-finix ];
}Import that alongside the finix-nixpkgs-shim battery from the top of §3.
# modules/den.nix
{
imports = [
./den/batteries/finix.nix
./den/batteries/finix-nixpkgs-shim.nix
];
den.hosts.x86_64-linux = {
finix-1 = { class = "finix"; users.alice = { }; };
finix-2 = { class = "finix"; };
plain-nixos = { }; # untouched, still lib.nixosSystem -> nixosConfigurations
};
# Correction 3: activation goes on the aspect, not the host entity.
den.aspects.finix-1.includes = [ den.aspects.base ];
den.aspects.finix-2.includes = [ den.aspects.base ];
# Aspects can now target 'finix' explicitly or rely on policy.route from 'nixos'
den.aspects.base = {
finix = {
services.finit.enable = true;
};
};
}- Aspect Compatibility: Built-in batteries emitting
${host.class}....(hostname, users, home-manager bridges) emitfinix....on Finix hosts. SincefinixSystemevaluates NixOS-shaped modules, most of these evaluate natively — but only where Finix actually declares the option. Verify per battery. - The
osclass does NOT reach a custom class. Den's built-inos-to-hostpolicy is hardcoded tonixosanddarwin(modules/aspects/batteries/os-class.nix), so anything written underos = { … }silently vanishes on afinixhost. Either writefinix = { … }directly, or register your own route:den.policies.os-to-finix = { host, ... }: lib.optional ((host.class or null) == "finix") ( den.lib.policy.route { fromClass = "os"; intoClass = "finix"; path = [ ]; } );
- Inert Option Shims: The
nixpkgsshim in §3 is one instance of a general pattern — when a Finix base module lacks an option a global battery emits, declare it inert. See the shim technique innix-on-droid.nix. - Home Manager Bridge: If a Finix host requires Home Manager and
inputs.home-managerhas nofinixModulesattribute, use Den'sden.lib.home-env.makeHomeEnvbridge (see thedroidHomeblock innix-on-droid.nix) to forward collectedhomeManagerclass payload.
If you want aspects to declare structured content that a third-party tool assembles into something other than a host system (such as Kubernetes manifests via Nixidy, infrastructure via Terranix, or custom disk/OCI images), use den.lib.policy.instantiate with a custom class.
This pattern is documented in reference/policies.mdx (policy.instantiate spec) and demonstrated in tutorials/terranix-demo.mdx.
-
Register a Custom Class:
den.classes.k8s-manifests.description = "Kubernetes manifests collected for nixidy";
(Note: For non-module structured payload such as lists of CRDs, manifests, or environment variables, register a companion quirk via
den.quirks.*instead. Seeguides/quirks.mdxandnixidy.nix). -
Write Aspects Emitting Payload:
den.aspects.monitoring = { k8s-manifests = { charts, ... }: { charts = [ { chart = charts.grafana.grafana; name = "grafana"; } ]; }; };
-
Instantiate via Policy & Builder:
den.policies.cluster-to-nixidy = { cluster, system, ... }: [ (den.lib.policy.instantiate { name = cluster.name; class = "k8s-manifests"; # Bucket to collect instantiate = { modules, ... }: inputs.nixidy.lib.mkEnv { inherit modules; # Den-collected class modules }; intoAttr = [ "nixidyEnvs" system cluster.name ]; # Flake output path }) ];
(Working reference:
cluster-to-nixidypolicy inclusters.nix).Per-Host Image Artifact Variant: To generate custom per-host Finix disk or OCI images alongside host system configs:
den.policies.host-to-finix-image = { host, ... }: [ (den.lib.policy.instantiate { name = host.name; class = "nixos"; instantiate = { modules, ... }: <image-builder> { inherit modules; }; intoAttr = [ "finixImages" host.system host.name ]; }) ];
Per-entity isolation is automatic:
policy.instantiatecollects only from the target entity's scope subtree, guaranteeing host A's modules never leak into host B's artifact.
| Requirement / Goal | Pattern | Explanation |
|---|---|---|
One or a few hosts build through finixSystem instead of nixosSystem |
Option A (Per-host override) | Simplest path. Keeps class = "nixos" and intoAttr = [ "nixosConfigurations" ... ]. Still needs the nixpkgs shim. |
Every nixos host should build through finixSystem fleet-wide |
Option A Variant (Schema-scoped override) | Overrides default instantiate for config.class == "nixos" fleet-wide. |
| A fleet of Finix hosts where builder and namespace are host properties | Option B (class = "finix" + schema import) |
Explicit class separation. Pairs with den.policies.nixos-to-finix to reuse nixos aspects. |
| Assemble Finix/NixOS aspects into non-OS artifacts (disk images, OCI images, K8s manifests) | Option C (Custom den.classes.* + policy.instantiate) |
Collects class modules across entities and feeds third-party builder functions (e.g. Nixidy, Terranix). |
Composition Note: Options A and B compose seamlessly with Option C. A host can evaluate finixSystem for its root system while using custom classes for extra build artifacts.
- Pass
libtofinixSystem. It defaults tonull. The failure surfaces asexpected a set but found null: nullinsidefinix/flake.nix, with a stack that usually points at Den'smodules/outputs.nix— misleading, but the cause is always the missinglib. - Declare
nixpkgs.hostPlatformandnixpkgs.config, and setnixpkgs.pkgs. See the §3 shim. Without it:The option 'nixpkgs.config' does not exist. - Activate aspects via
den.aspects.<hostName>.includes.den.hosts.<system>.<name>.includesis a freeform key Den never reads — it fails silently, with no warning and no error. pkgsHandling: Den'sinstantiatethunk convention ispkgs-less. Do not introduce a host-widepkgsoption, as it altersspec ? pkgsdetection across all hosts (seeNOTE on pkgsinnix-on-droid.nix). Setting it also suppresses thenixpkgs.hostPlatformmodule Den would otherwise append.finit, Notsystemd: Finix replacessystemdwithfinit. Aspect modules emittingsystemd.services.*orsystemd.tmpfileswill fail unless gated. Two distinct mechanisms, easy to confuse:- a route/deliver guard, which takes the target's module-system args:
den.lib.policy.route { fromClass = "nixos"; intoClass = "finix"; guard = { options, ... }: options ? systemd; }
- a conditional aspect, which uses
meta.guardand receives a resolution context, not module args (seereference/aspects.mdx, "meta.guard/meta.aspects").
- a route/deliver guard, which takes the target's module-system args:
specialArgs:inputsis in scope whereverinstantiateis defined. PassingspecialArgs = { inherit inputs; };is standard for forwarding flake inputs to modules. Finix merges it over{ modules = self.nixosModules; }.- Output Naming: Den disambiguates colliding output paths only across different systems, by appending
@<system>to the last segment. Two specs colliding on the same path and the same system are deduplicated to the last one with alib.warn— so do not rely on collision handling to keep both. - Skipping Output Placement (
intoAttr = [ ]): SettingintoAttr = [ ]suppresses output binding (useful for child configs consumed by parent builders). - Class vs. Quirk: Use classes when payload aspects emit NixOS/Finix modules. Use quirks (
den.quirks.*) when payload aspects emit raw structured data.
The following is the exact configuration used to verify this guide. It evaluates to a real derivation against den c7ef3f1 and finix 78fd549.
# flake.nix — Den's templates/minimal shell plus a finix input
{
description = "Minimal Den flake with a finix host";
inputs = {
nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz";
import-tree.url = "github:vic/import-tree";
den.url = "github:denful/den";
finix.url = "github:finix-community/finix";
};
outputs =
inputs:
(inputs.nixpkgs.lib.evalModules {
modules = [ (inputs.import-tree ./modules) ];
specialArgs = { inherit inputs; };
}).config.flake;
}# modules/den.nix
{
den,
lib,
inputs,
...
}:
{
imports = [ inputs.den.flakeModule ];
den.classes.finix.description = "finix system configuration";
den.schema.host.imports = [
(
{ config, ... }:
lib.mkIf (config.class == "finix") {
instantiate =
{ modules, ... }:
inputs.finix.lib.finixSystem {
inherit modules;
# finixSystem's `lib` argument defaults to null and is used as
# `lib.evalModules` — omitting it fails with "expected a set but
# found null".
lib = inputs.nixpkgs.lib;
specialArgs = { inherit inputs; };
};
intoAttr = [
"finixConfigurations"
config.name
];
}
)
];
# finix declares only `nixpkgs.pkgs`. Den appends `nixpkgs.hostPlatform` and
# its always-on unfree/insecure batteries emit `nixpkgs.config`; declare both
# and derive `nixpkgs.pkgs` from them.
den.default.includes = [
(
{ host, ... }:
{
name = "finix/nixpkgs-shim";
}
// lib.optionalAttrs ((host.class or null) == "finix") {
finix =
{ config, ... }:
{
options.nixpkgs = {
hostPlatform = lib.mkOption { type = lib.types.str; };
config = lib.mkOption {
type = lib.types.attrs;
default = { };
};
};
config.nixpkgs.pkgs = import inputs.nixpkgs {
system = config.nixpkgs.hostPlatform;
inherit (config.nixpkgs) config;
};
};
}
)
];
den.aspects.base.finix = {
networking.hostName = "slab";
fileSystems."/" = {
device = "/dev/null";
fsType = "ext4";
};
};
den.aspects.slab.includes = [ den.aspects.base ];
den.hosts.x86_64-linux.slab.class = "finix";
}# Evaluate the generated Finix configuration
$ nix eval .#finixConfigurations.slab.config.networking.hostName
"slab"
# Confirm the package set resolved through the nixpkgs shim
$ nix eval .#finixConfigurations.slab.pkgs.system
"x86_64-linux"
# Build the system toplevel derivation
$ nix eval .#finixConfigurations.slab.config.system.build.toplevel.drvPath
"/nix/store/…-finix-system.drv"Note fsType = "ext4" rather than "auto": Finix's boot/initrd.nix maps each fileSystems.*.fsType onto a boot.initrd.supportedFilesystems.<fs> option, and there is no auto member.
reference/output.mdx— Build pipeline, custom instantiation, custom output paths,den.systemsreference/policies.mdx—policy.instantiate,policy.route,policy.deliver,policy.providespecsreference/aspects.mdx—meta.guard/meta.aspectsconditional aspectsguides/custom-classes.mdx— Declaring custom classes,forwardTo/parentPathclass fields, worked examplesguides/quirks.mdx— Non-module payload channelsreference/schema.mdx— Host/Home option reference (class,instantiate,intoAttr)tutorials/terranix-demo.mdx— Complete third-party tool integration example (Terranix)templates/minimal— Minimal flake shape to copy from
Working References (sini/nix-config at Commit 01c9391efbdc)
modules/den/batteries/nix-on-droid.nix— Custom OS class (class = "droid") + custom builder + custom output + Home Manager bridge.modules/den/schema/host.nix— Fleet-level host schema with channel-awaremkDefaultinstantiate/home-manager.module.modules/den/batteries/nixidy.nix— Custom class + quirk registration, flake-level outputs derived from Den pipeline output.modules/den/policies/clusters.nix—policy.instantiatewith a third-party builder (mkEnv) per cluster/system.