Skip to main content

Overview

Dynamic discovery is the mechanism that automatically finds and builds configurations without requiring manual imports. Instead of maintaining lists of systems and users, the flake scans directories and constructs configurations on the fly.

The Problem

Traditional Nix flake configurations require explicit imports:
# Traditional approach ❌
nixosConfigurations = {
  server1 = mkSystem "server1" ./systems/server1;
  server2 = mkSystem "server2" ./systems/server2;
  server3 = mkSystem "server3" ./systems/server3;
  # ... must manually add every system
};
Problems:
  • Adding a new system requires updating the flake
  • Easy to forget to add new configurations
  • Boilerplate scales linearly with number of systems
  • Refactoring is tedious

The Solution

This homelab uses discovery functions that scan directories and build configurations automatically:
# Discovery approach ✅
nixosConfigurations = lib.mapAttrs mkSystem (lib.discover ./systems);
Benefits:
  • Add a new system → instantly available
  • Zero boilerplate maintenance
  • Scales to hundreds of systems
  • Refactoring is trivial

Discovery Functions

The discovery logic lives in lib/default.nix and provides three main functions.

discover - Configuration Discovery

Purpose: Finds directories with default.nix and standalone .nix files. Source code from lib/default.nix:4-14:
discover = dir:
  self.mapAttrs' (name: _: {
    name = self.removeSuffix ".nix" name;
    value = dir + "/${name}";
  }) (
    self.filterAttrs (
      name: type:
        (type == "directory" && builtins.pathExists (dir + "/${name}/default.nix"))
        || (type == "regular" && name != "default.nix" && self.hasSuffix ".nix" name)
    ) (builtins.readDir dir)
  );
How it works:
  1. Read directory: builtins.readDir dir returns an attribute set of { name = type; }
  2. Filter entries: Keep only:
    • Directories containing default.nix
    • Regular .nix files (except default.nix itself)
  3. Transform names: Remove .nix suffix from filenames
  4. Build paths: Create full paths to configurations
Example: Given this directory structure:
systems/
├── server/
│   └── default.nix
├── desktop/
│   └── default.nix
└── laptop.nix
The discover function returns:
{
  server = ./systems/server;
  desktop = ./systems/desktop;
  laptop = ./systems/laptop.nix;
}
This allows both directory-based configs (server/default.nix) and single-file configs (laptop.nix) to coexist.

discoverTests - Test Discovery

Purpose: Specifically designed to find and evaluate test files. Source code from lib/default.nix:17-26:
discoverTests = args: dir:
  self.mapAttrs' (name: _: {
    name = self.removeSuffix ".nix" name;
    value = import (dir + "/${name}") (args // {lib = self;});
  }) (
    self.filterAttrs (
      name: type:
        type == "regular" && self.hasSuffix ".nix" name
    ) (builtins.readDir dir)
  );
Differences from discover:
  • Only finds regular .nix files (no directories)
  • Immediately imports and evaluates each file
  • Passes args to each test with lib override
Usage:
checks = lib.discoverTests { inherit pkgs; } ./tests;
Given:
tests/
├── unit-test.nix
└── integration-test.nix
Returns:
{
  unit-test = import ./tests/unit-test.nix { pkgs = pkgs; lib = lib; };
  integration-test = import ./tests/integration-test.nix { pkgs = pkgs; lib = lib; };
}

readMeta - Metadata Reader

Purpose: Reads optional meta.json files from configuration directories. Source code from lib/default.nix:29-33:
readMeta = path:
  if builtins.pathExists (path + "/meta.json")
  then builtins.fromJSON (builtins.readFile (path + "/meta.json"))
  else {};
How it works:
  • Checks if meta.json exists in the given path
  • If yes: parses JSON and returns attribute set
  • If no: returns empty set {}
Usage in builders: From flake.nix:233-235:
mkSystem = hostName: path: let
  meta = lib.readMeta path;
  systemArch = meta.system or "x86_64-linux";
This allows overriding the target architecture:
// systems/raspi/meta.json
{
  "system": "aarch64-linux"
}
The or operator provides a default value when system is not specified in meta.json.

Discovery in Practice

Systems Discovery

From flake.nix:348:
nixosConfigurations = lib.mapAttrs mkSystem (lib.discover ./systems);
Process:
  1. lib.discover ./systems{ server = ./systems/server; ... }
  2. lib.mapAttrs mkSystem → Calls mkSystem "server" ./systems/server for each
  3. Result: { server = <nixosSystem>; ... }

Droids Discovery

From flake.nix:351:
nixOnDroidConfigurations = lib.mapAttrs mkDroid (lib.discover ./droids);
Identical pattern applied to Android devices.

Homes Discovery

Home Manager uses custom discovery logic to handle the three naming patterns. From flake.nix:355-374:
homeConfigurations = let
  # Read all entries in homes/
  allEntries = builtins.readDir ./homes;
  homeDirs = builtins.attrNames (lib.filterAttrs (_n: v: v == "directory") allEntries);

  # Filter for valid user directories
  # Valid: "alice" or "alice@global"
  # Invalid: "alice@workstation" (system-specific, not standalone)
  validUsers = lib.filter (
    name: (! lib.hasInfix "@" name) || (lib.hasSuffix "@global" name)
  ) homeDirs;

  # Normalize to base username
  # "alice" → "alice"
  # "alice@global" → "alice"
  usernames = lib.unique (map (name: lib.removeSuffix "@global" name) validUsers);
in
  lib.genAttrs usernames mkHome;
Step-by-step example: Given:
homes/
├── alice/
├── alice@global/
├── alice@workstation/
└── bob/
  1. allEntries = { alice = "directory"; alice@global = "directory"; ... }
  2. homeDirs = [ "alice" "alice@global" "alice@workstation" "bob" ]
  3. validUsers = [ "alice" "alice@global" "bob" ] (removed alice@workstation)
  4. usernames = [ "alice" "bob" ] (normalized and deduplicated)
  5. Final: { alice = <homeConfiguration>; bob = <homeConfiguration>; }
The alice@workstation directory is intentionally excluded from homeConfigurations because it’s meant to be imported by the NixOS system configuration, not used standalone.

The mkHome Builder

From flake.nix:166-204:
mkHome = username: let
  basePath = ./homes + "/${username}";
  globalPath = ./homes + "/${username}@global";

  # Check which paths exist
  hasBase = builtins.pathExists basePath;
  hasGlobal = builtins.pathExists globalPath;

  # Read metadata with fallback
  meta = if hasBase
    then lib.readMeta basePath
    else if hasGlobal
    then lib.readMeta globalPath
    else {};

  systemArch = meta.system or "x86_64-linux";
  pkgs = pkgsFor.${systemArch};
in
  inputs.home-manager.lib.homeManagerConfiguration {
    inherit pkgs;
    modules =
      homeManagerModules
      ++ lib.optional hasBase (basePath + "/default.nix")
      ++ lib.optional hasGlobal (globalPath + "/default.nix")
      ++ [ /* ... */ ];
  };
Key features:
  • Checks if both alice/ and alice@global/ exist
  • Combines them into a single configuration
  • Uses lib.optional to conditionally include modules
  • Respects architecture from metadata

Extending Discovery

Adding a New System Type

To add discovery for a new configuration type:
  1. Create a builder function:
mkEnvironment = name: path:
  # ... build logic
  1. Apply discovery:
environmentConfigurations = lib.mapAttrs mkEnvironment (lib.discover ./environments);
  1. Export in flake:
flake = {
  environmentConfigurations = lib.mapAttrs mkEnvironment (lib.discover ./environments);
};

Custom Discovery Functions

You can create specialized discovery for unique patterns:
# Discover only production systems
discoverProd = dir:
  lib.filterAttrs (name: path:
    let meta = lib.readMeta path;
    in (meta.environment or "dev") == "production"
  ) (lib.discover dir);

Benefits

Reduced Cognitive Load

No need to remember to update imports. Just create a directory and it’s automatically available.

Scalability

The approach handles 1 system as easily as 100 systems:
  • Same amount of code
  • Same performance characteristics
  • Same mental model

Refactoring Freedom

Renaming is as simple as renaming the directory:
mv systems/old-name systems/new-name
nixos-rebuild switch --flake .#new-name
No imports to update.

Type Safety

Discovery still maintains Nix’s evaluation guarantees:
  • nix flake check validates all discovered configs
  • Type errors caught at evaluation time
  • No “hidden” configurations

Inspection

You can see what was discovered:
# List all discovered systems
nix flake show | grep nixosConfigurations

# Inspect a specific discovery
nix eval .#nixosConfigurations --apply builtins.attrNames
# Output: [ "server" "desktop" "laptop" ]

Summary

Dynamic discovery provides:
  • Automation - Configurations found automatically
  • Simplicity - No import boilerplate
  • Scalability - Handles growth effortlessly
  • Flexibility - Supports multiple patterns
  • Safety - Still validated by nix flake check
The three functions—discover, discoverTests, and readMeta—work together to create a self-organizing configuration system that grows with your infrastructure.

Build docs developers (and LLMs) love