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
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.
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
./common, ./modules … —to every machine. You don’t have to remember to include them manually when adding a new host.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)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.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:
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”).{ ... }: Parameters configuring the machine. Variables with a ? are optional and have default fallback valuestype: 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.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.
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 configurationplus 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") ];
})
];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
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:
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.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;
};
};
};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
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 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.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 While this is a very cool setup, for beginners (like me) it’s hard to comprehend at first
.