Skip to content

Flake Outputs from Aspects

Hosts and homes turn into nixosConfigurations, darwinConfigurations and homeConfigurations without any extra setup (see Output). This guide covers everything else a flake exports: packages, apps, checks, dev shells, legacyPackages, and attributes that are not per-system at all, such as deploy or images.

Den registers one class per system-scoped flake output. An aspect key with one of these names is output content, the same way nixos is NixOS content:

ClassLands atTypical content
packagesflake.packages.<system>derivations
appsflake.apps.<system>{ type = "app"; program = ...; }
checksflake.checks.<system>derivations built by nix flake check
devShellsflake.devShells.<system>pkgs.mkShell { ... }
legacyPackagesflake.legacyPackages.<system>nested package sets, e.g. images.<host>
flakeflake.*any top-level attribute: deploy, images, lib, …

The first five are per-system: den evaluates the class once for each system and places the result under that system. The flake class is not per-system. Its content is a module over the whole flake, so it writes flake.<attr> itself, which is why it reads flake.flake.deploy on an aspect.

  1. Declare the output option.

    Den routes a class only when flake.<output> is a declared option. Without flake-parts nothing declares it, so import den’s merge-aware definition:

    modules/outputs.nix
    { inputs, ... }:
    {
    imports = with inputs.den.flakeOutputs; [
    packages
    apps
    devShells
    ];
    }

    inputs.den.flakeOutputs also has checks, legacyPackages, tests, and all (every output at once).

  2. Write the class on an aspect.

    den.aspects.tools = {
    packages = { pkgs, ... }: {
    inherit (pkgs) hello;
    };
    apps = { pkgs, lib, ... }: {
    hello = { type = "app"; program = lib.getExe pkgs.hello; };
    };
    devShells = { pkgs, ... }: {
    default = pkgs.mkShell { packages = [ pkgs.hello ]; };
    };
    };
  3. Put the aspect where den collects outputs.

    den.schema.flake-system.includes = [ den.aspects.tools ];

    flake-system is instantiated once per entry in den.systems, so packages.<system>.hello appears for every system, with or without a host on it.

Output collection follows the scope tree. The flake scope fans out to one flake-system scope per system. Each of those resolves the hosts and homes of that system. Each per-output route (packages-to-flake, apps-to-flake, …) collects its class from its flake-system scope and every scope below it:

flowchart LR
  F["flake<br/>den.schema.flake"] -->|"flake-to-systems<br/>one per den.systems"| S["flake-system<br/>den.schema.flake-system"]
  S -->|system-to-os-outputs| H["host<br/>+ den.default"]
  H --> U[user]
  S -->|system-to-hm-outputs| M[home]
  S -.->|"system-to-flake-parts<br/>(opt-in)"| P["flake-parts<br/>den.schema.flake-parts"]
  S & H & U & M -->|"packages, apps, checks,<br/>devShells, legacyPackages"| R["*-to-flake routes"]
  R -->|"only if flake.&lt;output&gt;<br/>is a declared option"| O["flake.&lt;output&gt;.&lt;system&gt;"]
  P -->|"packages-to-flake-parts"| PS["perSystem.packages"]
  PS --> O
  F & S & H & U -->|flake class| T["flake.*"]

So an output class is exported when the aspect carrying it is reached from:

  • den.schema.flake-system.includes: once per system, no host needed.
  • A host aspect, a user aspect, or den.default: exported under that host’s system.
  • A standalone home aspect (den.homes): exported under the home’s system.

An output class is not exported from:

  • den.schema.flake.includes. The root flake scope sits above flake-system, outside the subtree the routes collect. Use it for the flake class, not for packages.
  • A host with intoAttr = [ ]. That host isn’t instantiated or walked, so its aspects contribute nothing.
  • A named aspect that nothing includes. den.aspects.foo.packages on its own is only a definition, see below.

An aspect defined under den.aspects does nothing until something includes it. This is the whole story behind #586: the package only showed up while a host happened to include the aspect. Earlier docs pointed at den.schema.flake-packages, which isn’t a real schema kind. den.schema is freeform, so the wrong kind was accepted without any error. The registered flake kinds are flake, flake-system, flake-parts and default.

When an output is missing, work through this list:

SymptomCauseFix
flake.packages absent entirely, no errorNo flake.packages option (no flake-parts, no import)Import inputs.den.flakeOutputs.packages
Package exists only on systems that have hostsAspect reached through a host, not flake-systemAdd it to den.schema.flake-system.includes
Aspect in den.schema.flake.includes, no packageRoot scope isn’t collectedMove it to den.schema.flake-system.includes
Host’s packages missingHost has intoAttr = [ ]Include the aspect via flake-system instead
A system is missingIt isn’t in den.systemsSet den.systems explicitly

Output classes are ordinary modules evaluated per system. Their arguments are pkgs, lib, config and options. pkgs is inputs.nixpkgs.legacyPackages.<system>: it has no overlays and no config.allowUnfree. If you need a different package set, build it in the module, e.g. import inputs.nixpkgs { inherit system; overlays = ...; }. Reach inputs through the closure of the file that defines the aspect.

To depend on the entity, wrap the module in a parametric function. The outer function receives scope context, and the inner one is the module:

den.aspects.nh-tool.packages =
{ host }:
{ pkgs, ... }:
{
"sw-${host.name}" = pkgs.writeText "sw-${host.name}" host.name;
};

Put a parametric output class on every host through den.default or den.schema.host.includes, and read the host’s built configuration from the flake. Each host adds its own key under its own system:

{ den, config, ... }:
{
den.default.packages =
{ host }:
{ pkgs, ... }:
{
"${host.name}-vm" =
config.flake.nixosConfigurations.${host.name}.config.system.build.vm;
};
}

config here is the outer module’s config (the file-level argument), not the output module’s.

For a nested layout, such as the images.<host> of #664, use legacyPackages. The five output classes deep-merge attribute sets across aspects, so legacyPackages.images.a and legacyPackages.images.b from two aspects end up side by side.

A top-level attribute per host (deploy, images)

Section titled “A top-level attribute per host (deploy, images)”

The flake class works from the root, flake-system, host and user scopes, and a parametric { host } form gets one definition per host (#181):

{ den, config, ... }:
{
den.aspects.deploy-node.flake =
{ host }:
{
flake.deployNodes.${host.name}.hostname =
config.flake.nixosConfigurations.${host.name}.config.networking.hostName;
};
den.schema.host.includes = [ den.aspects.deploy-node ];
}

den.schema.flake does two jobs (#666):

  • den.schema.flake.includes holds the root of the output tree. Aspects and policies listed there resolve once, at the flake scope. That’s the place for flake-class content and for policies that fan out on their own.
  • As a module, it shapes the flake option den declares when flake-parts is absent. It’s where you set a type for your own attributes, or turn on strict mode (den.schema.flake = den.lib.strict; rejects undeclared attributes, see strict.nix). With flake-parts, flake-parts owns the flake option instead.

You have two ways to export outputs from aspects. They conflict with each other.

flake-parts already declares flake.packages, apps, checks, devShells and legacyPackages, so the *-to-flake routes are live as soon as den is imported. Don’t import inputs.den.flakeOutputs.* in this case: the option already exists. Everything in Where outputs are collected from applies unchanged. Den’s flake.packages merges with your own perSystem.packages as long as the names differ.

Route the class into flake-parts’ perSystem instead, and the output module gets the full perSystem arguments (config, self', inputs', and anything third-party perSystem modules add). The flake-parts-modules template is built this way:

{ den, ... }:
{
# Enter the flake-parts scope once per system.
den.schema.flake-system.includes = [ den.policies.system-to-flake-parts ];
# Route the `packages` class into perSystem.packages.
den.schema.flake-parts.includes = [
den.policies.packages-to-flake-parts
den.aspects.foo # aspects whose classes feed perSystem
];
# Turn off the direct route; otherwise both deliver the same names.
den.schema.flake-system.excludes = [ den.policies.packages-to-flake ];
}

Two things to know about this path:

  • Hosts aren’t below the flake-parts scope. Once packages-to-flake is excluded, a host’s or user’s packages are dropped unless you also resolve the hosts from the flake-parts scope. The template does that in perSystem-from-hosts.nix:

    den.policies.flake-parts-to-host = _:
    map (host: den.lib.policy.resolve.to "host" { inherit host; })
    (builtins.concatMap builtins.attrValues (builtins.attrValues den.hosts));
    den.schema.flake-parts.includes = [ den.policies.flake-parts-to-host ];
  • Keep both paths and you get duplicate names. If the direct route stays on while hosts also feed perSystem, the same package arrives twice and flake-parts stops with The option `flake.packages.x86_64-linux.<name>' is defined multiple times while it's expected to be unique.

Den ships a perSystem route for packages only. For devShells, numtide/devshell, treefmt and other perSystem modules, register a class and a route of your own. The template’s classes/ has one file per module, and Custom Classes explains the pattern.

You can also skip den for an output: a plain perSystem.packages.vm = ... next to your den modules works as usual (the default template does this). For a standalone program built from aspects, such as an nvf neovim (#322), see the nvf-standalone template: it resolves a custom class into a package from a flake-system aspect.

SituationResult
Two aspects, different names under packages.<system>Merged
Two aspects, same name, identical valueMerged (#674)
Two aspects, same name, different valuesden: the option `<name>' has conflicting definitions from multiple aspects
Same package from the direct route and perSystem (flake-parts)flake-parts’ “defined multiple times” error, see above
Several hosts or homesMerged. nixosConfigurations of many hosts don’t collide
flake-class attribute two or more levels deep from two aspectsLast one wins, no error (see the caution above)

The “defined multiple times while it’s expected to be unique” reports in #317 came from flake outputs without merge semantics. Den now builds the whole flake attribute set in one evaluation and hands it over as a single definition, so several hosts or homes no longer collide. You’ll still see that message if your own module defines an attribute that den also produces, such as a hand-written flake.nixosConfigurations.x or a perSystem package with the same name as an aspect’s. In that case, import the matching inputs.den.flakeOutputs.<name> or declare the option yourself:

{ lib, ... }:
{
options.flake.deploy = lib.mkOption {
type = lib.types.lazyAttrsOf lib.types.raw;
default = { };
};
}
ContributeCommunitySponsor