skip to content

From an Aspect to a NixOS System

This guide builds two NixOS hosts from nothing: web, a web server, and db, a database. Both need SSH and monitoring. You declare two kinds, host and user, a few aspects that carry NixOS configuration, and the hosts that use them. The hub turns each host into a nixosConfiguration.

An aspect is a named, reusable feature. It holds configuration for one or more classes, such as nixos, and can include other aspects. See Reusable Features.

The finished project has five files:

  • flake.nix
  • aspect-cnf.nix
  • Directorygen-modules/
    • deployment.nix
  • Directoryhardware/
    • web.nix
    • db.nix
  1. flake.nix
    {
    inputs = {
    gen.url = "github:sini/gen";
    nixpkgs.follows = "gen/nixpkgs";
    flake-parts.url = "github:hercules-ci/flake-parts";
    flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
    };
    outputs = inputs:
    inputs.flake-parts.lib.mkFlake { inherit inputs; } {
    imports = [ inputs.gen.flakeModules.default ];
    systems = [ "x86_64-linux" ];
    gen = {
    tree = ./gen-modules;
    aspectCnf = import ./aspect-cnf.nix;
    nodeRegistryPath = [ "hosts" ]; # (1)
    extraModules.web = [ ./hardware/web.nix ]; # (2)
    extraModules.db = [ ./hardware/db.nix ];
    };
    };
    }
    1. Each member of the hosts registry becomes one NixOS system.
    2. Per-host NixOS modules. Here they hold the platform and file systems:
    # hardware/web.nix (db.nix is the same)
    {
    nixpkgs.hostPlatform = "x86_64-linux";
    boot.loader.grub.enable = false;
    fileSystems."/" = { device = "none"; fsType = "tmpfs"; };
    }

    Add gen to a Flake lists every hub option.

  2. aspect-cnf.nix
    {
    keySemantics.nixos.category = "class";
    }

    This file says that an aspect’s nixos key holds NixOS modules. The hub reads it to decide what to deliver, and the tree reads it to build the aspect schema. Both import the same file so they always agree.

  3. A kind is a typed record type. Every host and every user is an instance of one. The rest of this step and the next ones are in gen-modules/deployment.nix.

    { config, genAspects, genMerge, genSchema, ... }: # (1)
    let
    inherit (genMerge) mkOption types;
    aspectSchema = genAspects.mkAspectSchema (import ../aspect-cnf.nix);
    schema = genSchema.evalSchema { schemaOption = aspectSchema.schemaOption; } [ {
    config.schema.host = {
    options.aspects = mkOption { type = types.listOf types.str; default = [ ]; }; # (2)
    };
    config.schema.user = {
    options.hosts = mkOption { type = types.listOf types.str; default = [ ]; };
    options.admin = mkOption { type = types.bool; default = false; };
    };
    } ];
    1. The hub passes these gen libraries to every module in the tree.
    2. The hub delivers the aspects named in each host’s aspects list.
  4. in
    {
    imports = [ (aspectSchema.mkAspectModule { }) ]; # (1)
    options.hosts = genSchema.mkInstanceRegistry { } schema.host; # (2)
    options.users = genSchema.mkInstanceRegistry { } schema.user;
    config = {
    hosts.web.aspects = [ "webserver" ]; # (3)
    hosts.db.aspects = [ "database" ];
    users.deploy = { hosts = [ "web" "db" ]; admin = true; };
    users.dba.hosts = [ "db" ];
    1. Declares the aspects option, built from the class declaration in step 2.
    2. A registry holds the instances of one kind. Each instance gets its name and a stable identity. See Stable Identity for Everything.
    3. A host lists only the aspects it asks for. Delivery adds everything they include.
  5. aspects.base.nixos = { node, ... }: { # (1)
    networking.hostName = node.name;
    users.users = builtins.listToAttrs (map (u: { # (2)
    name = u;
    value = {
    isNormalUser = true;
    extraGroups = if config.users.${u}.admin then [ "wheel" ] else [ ];
    };
    }) (usersOn node.name));
    system.stateVersion = "25.05";
    };
    aspects.ssh.includes = [ "base" ]; # (3)
    aspects.ssh.nixos.services.openssh.enable = true;
    aspects.monitoring.includes = [ "base" ];
    aspects.monitoring.nixos.services.prometheus.exporters.node.enable = true;
    aspects.webserver.includes = [ "ssh" "monitoring" ];
    aspects.webserver.nixos.services.nginx.enable = true;
    aspects.database.includes = [ "ssh" "monitoring" ];
    aspects.database.nixos.services.postgresql.enable = true;
    };
    }
    1. node is the host instance being built. gen passes it in by name, so the same aspect serves every host. See Automatic Argument Injection.
    2. A query over the users registry: the users that list this host.
    3. ssh includes base, so any host with ssh also gets base.

    The helper goes in the let block from step 3:

    usersOn = host:
    builtins.filter (u: builtins.elem host config.users.${u}.hosts)
    (builtins.attrNames config.users);

    Delivery follows includes: a host receives the content of the aspects it lists and of everything they include, so web gets webserver, ssh, monitoring and base.

  6. Terminal window
    nix eval .#nixosConfigurations --apply builtins.attrNames
    # [ "db" "web" ]
    nix eval --json .#nixosConfigurations.db.config.services.openssh.enable
    # true
    nix eval --json .#nixosConfigurations.db.config.users.users.deploy.extraGroups
    # ["wheel"]
    nix build .#nixosConfigurations.db.config.system.build.toplevel

    db runs PostgreSQL from database, and SSH, the node exporter and its host name from ssh, monitoring and base, which it reaches through includes. It has accounts for deploy and dba. web runs nginx and has an account for deploy only.

  1. The hub merged gen-modules/ with gen’s own module system and produced the composed values: hosts, users and aspects.
  2. For each member of hosts, it collected the nixos content of every aspect in that host’s aspects list. The class declaration in aspect-cnf.nix decided which keys count as content to deliver.
  3. It passed each host’s modules, with node bound to the host, plus its extraModules, to nixpkgs.lib.nixosSystem. The result is nixosConfigurations.<host>.

A framework such as den hides most of this file behind its own vocabulary. The kinds, registries, aspects and delivery underneath are the same.

palette
dark
light
↑↓ select↵ apply esc close

Palettes adapted from Catppuccin (Macchiato) (MIT), Tokyo Night (Apache-2.0), gruvbox (MIT), Catppuccin (Latte) (MIT), Rosé Pine (Dawn) (MIT).