NixOS for all my system - part 3

Posted in linux, nixos on May 28, 2026 by Adrian Wyssmann ‐ 9 min read

While nixos-anywhere is fantastic for getting a fresh server bootstrapped from scratch, managing updates across an entire homelab one-by-one quickly becomes tedious. As I mentioned my last post, I want to make the management of my home-lab less tedious. So how we can achieve that? While there are different solutions I have chosen colmena

What is Colmena?

Colmena is a build and deployment tool for NixOS. If you have multiple machines running NixOS in your home network or on the cloud, Colmena allows you to configure them all from a single central computer and deploy those configurations over SSH simultaneously.

Think of it as Ansible or Terraform, but tailored specifically for the Nix ecosystem.

Instead of logging into a remote server and running nixos-rebuild switch, you define your hosts inside your central flake.nix file.

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";

  outputs = { self, nixpkgs, ... }: {
    colmena = {
      meta = {
        # The nixpkgs dependency used for the configuration
        nixpkgs = import nixpkgs { system = "x86_64-linux"; };
      };

      # Define a host named "webserver"
      webserver = {
        deployment = {
          targetHost = "192.168.1.50"; # Remote IP address
          targetUser = "root";
        };

        # Standard NixOS configuration for this machine
        boot.loader.grub.device = "/dev/sda";
        fileSystems."/" = { device = "/dev/sda1"; fsType = "ext4"; };
        services.nginx.enable = true; 
      };
    };
  };
}

Then u run colmena apply and Colmena will build the Nginx configuration locally, copy the necessary components over SSH to 192.168.1.50, and safely activate it.

So far so simple.

What do I want to achieve?

As a homelab grows, configuring every single host manually inside a single block of code becomes messy. You quickly find yourself copy-pasting the same inputs, modules, etc. for every system. To solve this, your updated configuration introduces a Host Factory via the mkSystem function. So you have basically 3 building blocks:

┌────────────────┐      ┌─────────────────────┐      ┌──────────────────────┐
│    INVENTORY   │      │    HOST FACTORY     │      │   DEPLOYMENT TOOLS   │
│  (Your Hosts)  │ ───> │     (mkSystem)      │ ───> │  (Colmena / NixOS)   │
│ lenovo / envy  │      │ Bundles common code │      │ Applies config to IP │
└────────────────┘      └─────────────────────┘      └──────────────────────┘
  • The Inventory: You simply type out a list of your hardware’s basic metadata (the hostname, its role like server or pc, and its IP address).

  • The Factory (mkSystem): This is the engine. It takes your minimal inventory list, automatically attaches your security layer, configures the hard drives, and injects the correct profiles based on whether the machine is a desktop or a server.

                      ┌──────────────────────────────┐
                      │      mkSystem (Factory)      │
                      └──────────────┬───────────────┘
            ┌───────────────────────┼───────────────────────┐
            ▼                       ▼                       ▼
    ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
    │     lenovo      │     │      envy       │     │   clawfinger    │
    │ (type = server) │     │ (type = server) │     │   (type = pc)   │
    └─────────────────┘     └─────────────────┘     └─────────────────┘
  • The Deployment Engine (Colmena): This tool reads the final package from the factory, looks at the IP address you provided, and safely pushes the changes over the network

This setup allows for

  • DRY (Don’t Repeat Yourself) Architecture: Your factory automatically attaches critical baselines—such as ./common, ./modules … —to every machine. You don’t have to remember to include them manually when adding a new host.
  • Context-Aware Module Injection: The factory reads the type parameter (e.g., “server”, “pc”, “cloud”) and automatically injects conditional system profiles. For instance, if type = "server", it implicitly appends ./profiles/servers into the build layout. If type = "pc", it injects specific modules only valid for your pc (e.g. developer machine)
  • Dual-Target Compilation Outputs: This is perhaps the coolest advantage. The factory structures your configuration attributes once, but outputs them in two distinct formats simultaneously at the bottom of your flake:
    • nixosConfigurations: Standard outputs compatible with everyday vanilla commands and tools like nixos-anywhere (crucial for that first-time bare-metal bootstrap process).
    • colmena: Deployment-ready entries filtered to include your remote network IP mappings, allowing you to run bulk rolling updates using colmena apply.

How does it look like?

The factory

This is more or less how my factory looks like

# --- HOST FACTORY ---
mkSystem = name: {
    type,
    version ? "25.11",
    system ? "x86_64-linux",
    device ? "/dev/sda",
    deployment ? null 
  }:

  let
    isRpi = type == "rpi";
    isCloud = type == "cloud";
    isServer = type == "server";
    isPC = type == "pc";

    lib = nixpkgs.lib;

    moduleList = [
      ./hosts/${name}
      ./common
      inputs.sops-nix.nixosModules.sops
      disko.nixosModules.disko
      inputs.home-manager.nixosModules.home-manager
      ({ ... }: {
        imports =
          lib.optionals (type == "pc") [
              ./profiles/pc
            ]
          ++ lib.optionals (type == "server") [
              ./profiles/servers
            ]
          ++ lib.optionals (type == "cloud") [
              (nixpkgs + "/nixos/modules/profiles/qemu-guest.nix")
          ];
      })

    ];

    specialArgs = {
      inherit self inputs name isRpi version rpiVersion pkgs-unstable;
      type = type;
      hosts = self.hosts;
      isCloud = type == "cloud";
      disko = inputs.disko;
      home-manager = inputs.home-manager;
    };
  in
  {
    inherit deployment moduleList specialArgs system type version rpiVersion;
    pkgs = nixpkgsFor.${system};

    nixosConfig = nixpkgs.lib.nixosSystem {
        inherit system specialArgs;
        modules = moduleList;
      };
    };

Let’s breakt it down:

Input Arguments

mkSystem = name: {
    type,
    version ? "25.11",
    system ? "x86_64-linux",
    device ? "/dev/sda",
    deployment ? null 
  }:
  • name: The hostname of the machine (e.g., “lenovo” or “envy”).
  • The Attribute Set { ... }: Parameters configuring the machine. Variables with a ? are optional and have default fallback values
  • type: This is a mandatory flag (like “pc”, “server”, or “cloud”) that decides which profiles get baked in.
  • deployment: Contains the Colmena specific parameters (like IP addresses and users) if the node is intended for remote updates.

Local Variable Assignments (let … in)

let
  isRpi = type == "rpi";
  isCloud = type == "cloud";
  isServer = type == "server";
  isPC = type == "pc";

  lib = nixpkgs.lib;

Instead of writing type == "cloud" multiple times later in the code, I define boolean flags by checking the type parameter.

Assembling the Module Pipeline (moduleList)

The moduleList array compiles every piece of code required to build the target machine’s operating system.

moduleList = [
  ./hosts/${name}                               # 1. Host-specific hardware/disko configs
  ./common                                      # 2. Baseline rules for all your machines
  inputs.sops-nix.nixosModules.sops             # 3. Secret management framework
  disko.nixosModules.disko                      # 4. Disk partitioning framework
  inputs.home-manager.nixosModules.home-manager # 5. User space configuration

plus optional modules, depending on the flags

  ({ ... }: {
    imports =
      lib.optionals (isPC) [ ./profiles/pc ]
      ++ lib.optionals (isServer) [ ./profiles/servers ]
      ++ lib.optionals (isCloud) [ (nixpkgs + "/nixos/modules/profiles/qemu-guest.nix") ];
  })
];

Passing Metadata downward (specialArgs)

specialArgs = {
  inherit self inputs name isRpi version rpiVersion pkgs-unstable;
  type = type;
  hosts = self.hosts;
  isCloud = type == "cloud";
  disko = inputs.disko;
  home-manager = inputs.home-manager;
};

NixOS modules typically only have access to standard arguments (like config, pkgs, and lib). By packaging variables into specialArgs, you are telling NixOS: “Pass these custom variables down into every single sub-module folder.” This means your files inside ./hosts/lenovo or ./common can read custom parameters like name, type, or your entire inventory list (hosts) directly. This is especially cool if you have to define e.g. the ip address or other host specific values. You will have to define them only in the flake.nix

Structuring the Final Output Asset

Finally, the in statement constructs the output object.

{
  inherit deployment moduleList specialArgs system type version rpiVersion;
  pkgs = nixpkgsFor.${system};

  nixosConfig = nixpkgs.lib.nixosSystem {
      inherit system specialArgs;
      modules = moduleList;
    };
};

Instead of returning just a raw system configuration, it returns a multifaceted attribute set containing:

  • All the calculated metadata tags (deployment, moduleList, system, etc.).
  • pkgs: The specific architecture instance of nixpkgs that matches the target machine’s required platform architecture type.
  • nixosConfig: The actual compiled object generated by running nixpkgs.lib.nixosSystem. This object is what tools like nixos-anywhere look for to evaluate your hardware profiles, format filesystems, and build the physical system environment.

Host definition

Inside flake.nix I have a host object which defines all hosts, using the structure define by the factory:

# Helper to define our hosts in one place
hosts = {
  clawfinger = mkSystem "clawfinger" {
    type = "pc";
  };
  envy = mkSystem "envy" {
    type = "server";
    deployment = {
      targetHost = "10.0.0.10";
      targetUser = "nixos";
    };
  };
  lenovo = mkSystem "lenovo" {
    type = "server";
    deployment = {
      targetHost = "10.0.0.61";
      targetUser = "nixos";
    };
  };
  rpi4-a = mkSystem "rpi4-a" {
    type = "rpi";
    system = "aarch64-linux";
    deployment = {
      targetHost = "10.0.0.11";
      targetUser = "nixos";
      # Allows x86_64 machine to build for aarch64 (needs binfmt)
      allowLocalDeployment = true;
    };
  };
};

Colmena integration

At last we need the colmena integration:

colmena = {
  meta = {
    # The default nixpkgs instance used by Colmena for internal operations
    nixpkgs = nixpkgsFor."x86_64-linux"; 
    
    # Dynamically map the correct nixpkgs architecture for each individual host
    nodeNixpkgs = builtins.mapAttrs (name: h: h.pkgs) self.hosts;
    
    # Forward our custom factory specialArgs down to the Colmena deployment nodes
    nodeSpecialArgs = builtins.mapAttrs (name: h: h.specialArgs) self.hosts;
  };
} // (builtins.mapAttrs (name: h: {
    # Extract the deployment metadata (IP address, target user, etc.)
    deployment = h.deployment;
    
    # Feed the host's unified module list directly into Colmena
    imports = h.moduleList;
  }) (nixpkgs.lib.filterAttrs (name: h: h.deployment != null) self.hosts));

Let’s break this down as well

The Global Configuration (meta)

Colmena requires a central set of metadata to understand how to handle the packages and evaluations across different architectures.

  • nixpkgs: This sets the default version of nixpkgs that Colmena uses for its own deployment tasks.
  • nodeNixpkgs: builtins.mapAttrs iterates through your hosts database and assign each machine its architecture-native package set (h.pkgs) built by your factory.
  • nodeSpecialArgs: This maps the global parameter arguments created in your factory (like name, type, and inputs) down to Colmena, ensuring your remote configuration modules can reference them seamlessly during an active deployment.

The Node Injector & Filter Loop

The bottom section uses a Nix attribute set update operator (//) to dynamically merge your global meta block with your active deployment inventory.

  • nixpkgs.lib.filterAttrs: Screens your hosts list and if a machine does not have a deployment block filled out with an IP address, it is skipped (e.g. my PC). This prevents Colmena from attempting to manage machines that aren’t reachable remote servers.
  • builtins.mapAttrs: For every host that passes the filter, this loop automatically builds a standard Colmena configuration node.
  • deployment = h.deployment: This pulls the network parameters—such as targetHost = "10.0.0.61"; directly into Colmena’s configuration runtime.
  • imports = h.moduleList: Because your mkSystem factory already bundled up all your configs, we can pass that entire clean list directly into Colmena with a single line.

Running it

Now I can run colmena apply or even a targeted run for some of my hosts colmena apply --on envy,lenovo:

[INFO ] Using flake: git+file:///home/papanito/Workspaces/papanito/nixos-configuration
[INFO ] Enumerating nodes...
evaluation warning: 'system' has been renamed to/replaced by 'stdenv.hostPlatform.system'
[INFO ] Selected 2 out of 3 hosts.
       ✅ 17s All done!
 (...) ✅ 12s Evaluated envy and lenovo
  envy ✅ 0s Built "/nix/store/p5i2d8y6ismbimi7qrj27i5w698ax4f9-nixos-system-envy-25.11pre-git"
lenovo ✅ 0s Built "/nix/store/wf3p2h028y13w2v4r48dni7ggfqfyj23-nixos-system-lenovo-25.11pre-git"
  envy ✅ 1s Pushed system closure
lenovo ✅ 1s Pushed system closure
lenovo ✅ 3s Activation successful
  envy ✅ 3s Activation successful 

Conclusion and next

While this is a very cool setup, for beginners (like me) it’s hard to comprehend at first

.