Day 21 — Colmena, Shared Modules & nixos-generators
Day 60 — Colmena
Stage VI · ~4h
Goal: Express a small hive with Colmena, deploy at least two nodes (one may be local), use tags, and know when Colmena wins over deploy-rs for fleet shape. Hosts pin NixOS 26.05.
deploy-rs is excellent node/profile oriented glue. Colmena shines when you think in hives: many hosts, tags (@web, @db), parallel apply, and a flake hive attribute. Real orgs often evaluate both; leave Day 70 with a deliberate choice, not two half-wired tools forever.
Why this day exists
One remote deploy proves the pipe. Fleets need selection, defaults, and blast-radius control. Colmena’s mental model maps cleanly onto roles you will build on Day 61.
Theory 1 — Hive mental model
meta (nixpkgs, specialArgs)
nodes
host-a → NixOS module stack
host-b → …
defaults → shared modules
| Concept | Role |
|---|---|
meta.nixpkgs |
Package set for evaluation |
defaults |
Applied to every node |
nodes.<name> |
Per-host config + deployment keys |
| tags | Selection subsets for apply |
specialArgs |
Inject libs/secrets helpers carefully |
Theory 2 — Flake-based Colmena (modern)
{
description = "Day 60 colmena hive";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs, ... }: {
colmena = {
meta = {
nixpkgs = import nixpkgs { system = "x86_64-linux"; };
# specialArgs = { inherit something; };
};
defaults = { pkgs, ... }: {
time.timeZone = "UTC";
services.openssh.enable = true;
environment.systemPackages = [ pkgs.vim pkgs.curl ];
system.stateVersion = "26.05";
};
edge = { name, nodes, pkgs, ... }: {
deployment = {
targetHost = "edge.lab.internal";
targetUser = "root";
tags = [ "edge" "web" ];
};
networking.hostName = "edge";
imports = [ ./hosts/edge ];
};
core = {
deployment = {
targetHost = "core.lab.internal";
tags = [ "core" ];
# allowLocalDeployment = true; # when node is the machine itself
};
networking.hostName = "core";
imports = [ ./hosts/core ];
};
};
};
}nix shell nixpkgs#colmena -c colmena apply
nix shell nixpkgs#colmena -c colmena apply --on @web
nix shell nixpkgs#colmena -c colmena apply-local --sudo # local node patternsCLI details (colmena apply --on host)—run colmena --help for your version. Some pins expose colmenaHive—follow current Colmena flake docs if colmena attr alone is insufficient.
Theory 4 — deploy-rs vs Colmena (honest)
| Dimension | deploy-rs | Colmena |
|---|---|---|
| Flake-native profiles | Strong | Strong hive attr |
| Multi-host UX | Nodes map | Hive + tags first-class |
| Magic rollback | Notable feature | Different tooling |
| Learning curve | Lower for 1 node | Pays off at N>2 |
| Ecosystem | Serokell | zhaofengli / community |
House rule suggestion: deploy-rs for 1–2, Colmena when tags appear—or standardize on one to reduce cognitive load. Pick deliberately by Day 70 and document in FLEET-TOOLING.md.
Theory 5 — Build & push options
Colmena can build locally or on targets depending on deployment.buildOnTarget and related options:
deployment = {
targetHost = "edge.lab.internal";
buildOnTarget = false;
# tags = [ "edge" ];
};| Mode | When |
|---|---|
| Build local, push | Fast admin machine; matching arch |
| Build on target | Weak laptop; strong nodes |
| Remote builder + cache | Day 54 + 53 machinery |
Combine with Day 53 caches so N nodes do not compile N times.
Theory 6 — Evaluation cost and hive growth
Large hives evaluate all nodes for some operations. Habits:
| Habit | Why |
|---|---|
| Thin defaults | Shared eval cost is real |
| Avoid importing desktop on servers | Closure + eval bloat |
colmena eval / dry-run |
Catch errors before blast |
| Split lab vs prod flakes if needed | Cognitive firewall |
Worked example — two-node visible drift control
# hosts/edge/default.nix
{ pkgs, ... }:
{
environment.etc."role".text = "edge\n";
networking.firewall.allowedTCPPorts = [ 80 ];
# services.caddy… optional
}# hosts/core/default.nix
{
environment.etc."role".text = "core\n";
}colmena apply --on edge
ssh edge cat /etc/roleLab — multi-step
Suggested notes: ~/lab/90daysofx/02-nixos/day60
Step 1 — Second node
Provision second VM or treat laptop as allowLocalDeployment node + one remote.
Step 2 — Express hive
Move shared SSH/firewall defaults into defaults; keep host-only bits in nodes. Commit lockfile.
Step 3 — First full apply
colmena applyCapture output; note parallel vs serial behavior.
Step 4 — Tag apply
Tag one host canary; apply only canary; then the rest.
Step 5 — Parallel observation
Apply both; note wall clock vs serial deploy-rs mental model.
Step 6 — Intentional drift fix
SSH to a node; make a manual /etc change on a managed file; re-apply Colmena; confirm declarative truth wins (for managed paths).
Step 7 — Tool choice note
FLEET-TOOLING.md: when to use Colmena vs deploy-rs in your monorepo; one default for Capstone A.
Step 8 — Optional eval-only
colmena eval -E '{ nodes, ... }: builtins.attrNames nodes'(Adjust to supported eval interface in your Colmena version.)
Step 9 — Failure notes
Document one failed apply (intentional or accidental) and recovery. Link Day 89 rollback thinking.
Parallelism and blast radius
colmena apply --on @web
│
├─ edge-1 ─┐
├─ edge-2 ─┼─ simultaneous pressure on cache/builder
└─ edge-3 ─┘
| Control | Use |
|---|---|
| Tags / canary | Limit who changes first |
| Cache warm | Avoid N compile storms |
| Console access | Survive firewall mistakes |
| One tool default | deploy-rs or Colmena per host class |
Never demo “apply all prod” on day one of Colmena.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Hive not discovered | Flake output name colmena / colmenaHive per version docs |
| Wrong nixpkgs system | meta.nixpkgs system must match builds |
| Tags typo | List tags; dry-run selection |
| Local node confusion | allowLocalDeployment / apply-local |
| Double firewall lockout | Console access |
| Gigantic simultaneous compiles | Cache + buildOnTarget policy |
stateVersion missing on defaults |
Set "26.05" deliberately |
| Mixing deploy-rs + Colmena on same host ad hoc | One tool per host class |
Checkpoint
- Hive evaluates
- Two nodes deployed (or local+remote)
- Tag-filtered apply works
- Drift overwritten by apply
- Tooling choice written
Commit
git add .
git commit -m "day60: colmena hive and tagged apply"Write three personal gotchas before continuing.
Tomorrow
Day 61 — Shared modules / roles: stop copy-pasting host configs.
Day 62 — Images & nixos-generators
Stage VI · ~4h
Goal: Build a bootable artifact (cloud image, ISO, or VM format) from your flake modules using nixos-generators (and related patterns), then boot it once and prove it is the same module graph as a live host—not a snowflake fork.
Images are first-class deploy targets in Stage VI, not a side quest. The win is one module tree → install path and golden image path. NixOS 26.05 pin stays consistent with the rest of this volume.
Why this day exists
Not every node is nixos-install by hand. Cloud and edge want images: qcow2, AMIs (via pipelines), raw disks, installer ISOs. Generating images from the same modules as deploy targets closes a huge consistency gap—and catches “works on my VM but not in the AMI” earlier.
Without an image story you either:
- Hand-maintain cloud user-data that reimplements half of NixOS, or
- Drift image hosts away from Colmena/deploy-rs nodes until nobody trusts either.
Theory 1 — Image = NixOS config + format
same modules as hosts/edge
│
▼
nixos-generators / make-disk-image / sd-image
│
▼
artifact (iso, qcow2, raw, gce, azure, …)
| Format (examples) | Use |
|---|---|
iso |
Installer / live recovery |
qcow / qcow2 / vmware / virtualbox |
Hypervisors |
amazon / gce / azure |
Cloud (tooling evolves) |
sd-card / raw |
SBC / dd |
Check the current nixos-generators format list for your pin—names and defaults change. Prefer the project README over stale blog posts.
Mental model
An image build is still a NixOS evaluation: modules merge, activation scripts exist, store paths land on a disk layout the format owns. You are not “exporting files”; you are realizing a system derivation into a disk picture.
Theory 2 — flake + nixos-generators
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
inputs.nixos-generators.url = "github:nix-community/nixos-generators";
inputs.nixos-generators.inputs.nixpkgs.follows = "nixpkgs";
outputs = { self, nixpkgs, nixos-generators, ... }: {
packages.x86_64-linux.edge-qcow = nixos-generators.nixosGenerate {
system = "x86_64-linux";
format = "qcow";
modules = [
./modules/profiles/baseline.nix
./hosts/edge/image.nix
];
};
packages.x86_64-linux.lab-iso = nixos-generators.nixosGenerate {
system = "x86_64-linux";
format = "iso";
modules = [ ./hosts/lab-iso.nix ];
};
};
}nix build .#edge-qcow -L
ls -lh result*
# often result is a symlink to the image file or a directory containing itfollows discipline
Always pin generators’ nixpkgs to your flake’s nixpkgs. Mixed pins produce “image builds but host rebuilds something else” headaches that look like ghosts.
Theory 3 — Image-specific config
Images need first-boot realities: growpart, cloud-init or pure Nix alternatives, SSH keys baked or injected, serial console, predictable hostnames.
# hosts/edge/image.nix
{ modulesPath, pkgs, ... }:
{
imports = [
# sometimes:
# (modulesPath + "/profiles/qemu-guest.nix")
];
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA… image-lab"
];
# Disallow password auth even on images
services.openssh.settings.PasswordAuthentication = false;
networking.hostName = "edge-image";
system.stateVersion = "26.05";
# Smaller images: careful with documentation packages
documentation.nixos.enable = false;
documentation.man.enable = false;
}| Concern | Image habit |
|---|---|
| SSH access | Baked lab key or cloud metadata; never password-only |
| Disk grow | Guest agents / cloud-init / nixos grow modules |
| Console | Serial-friendly; qemu-guest profiles when KVM |
| Hostname | Static lab name vs cloud-init rewrite—pick deliberately |
| Docs packages | Off for edge images; on for installer ISOs if you want man pages |
Cloud-init vs pure NixOS images is a design choice; lab can use baked keys and skip cloud-init entirely.
Theory 4 — Consistency with deploy hosts
| Risk | Mitigation |
|---|---|
| Image modules diverge from live host | Share roles/* and profiles/* |
| Disko vs image partitioning | Images often use generator partitioning; Disko for bare metal |
| Secrets in images | Never bake production private keys into public artifacts |
| Huge closure | Slim profiles for images |
Different stateVersion |
One policy; document exceptions |
Theory 5 — Alternatives awareness
| Tool | Notes |
|---|---|
nixos-generators |
Friendly multi-format entry point |
make-disk-image internals |
Under many formats; lower-level |
nixos-rebuild build-vm |
Quick smoke VM, not always production image |
| Packer + Nix | Cross-cloud pipelines |
| Discovered AMI publish | Separate CI concern (Day 66+) |
nixos-generators + upload scripts |
Your glue, not the generator’s job |
Theory 6 — Size, caches, and CI
Image builds are full system closures. First build is slow; second should hit substitutes.
| Lever | Effect |
|---|---|
| Binary cache with your system paths | Huge wall-clock win |
documentation.*.enable = false |
Noticeable MB |
| Drop unused locales / firmware | Context-dependent |
| Separate “lab fat” vs “edge slim” profiles | Avoid one profile for all |
| CI artifact store | Keep last N images; GC ruthlessly |
Record image size in notes every rebuild during the lab—train the instinct that modules have mass.
Theory 7 — Security of publishable artifacts
Anything you upload to object storage is world-readable unless you lock it down.
# before publish: secret hygiene on image module path
rg -n "PRIVATE|AGE-SECRET|BEGIN OPENSSH PRIVATE|sk-" hosts modules || true| OK to bake | Never bake |
|---|---|
| Lab public SSH keys | Age/sops private keys |
| Hardened sshd defaults | Cloud API tokens |
| Public CA certs | Production host private keys |
| Non-secret config | networking.wireless PSKs |
Production: inject secrets at first deploy (Day 34–35 patterns) or via cloud metadata your threat model accepts.
Worked example — boot qcow with QEMU
nix build .#edge-qcow -L
IMG=$(readlink -f result)
# if result is a dir, find the qcow2:
find result -name '*.qcow2' -o -name '*.img'
qemu-system-x86_64 \
-enable-kvm \
-m 2048 \
-drive file="$IMG",if=virtio \
-nic user,hostfwd=tcp::2222-:22 \
-nographic
# ssh -p 2222 root@127.0.0.1Adjust for copy-on-write overlay so you do not dirty the store image:
qemu-img create -f qcow2 -b "$IMG" -F qcow2 /tmp/edge-overlay.qcow2
qemu-system-x86_64 \
-enable-kvm -m 2048 \
-drive file=/tmp/edge-overlay.qcow2,if=virtio \
-nic user,hostfwd=tcp::2222-:22 \
-nographicLab — multi-step
Suggested notes: ~/lab/90daysofx/02-nixos/day62
Step 1 — Add generator input
Pin; follows nixpkgs; commit flake.lock.
Step 2 — Define slim image module
Reuse baseline; bake lab SSH key only. Set system.stateVersion = "26.05".
Step 3 — Build one format
Prefer qcow/iso based on what you can boot easily.
nix build .#edge-qcow -L
du -h $(readlink -f result)Step 4 — Boot once
QEMU, VirtualBox, or cloud upload. Capture login proof (SSH or console) in notes.
Step 5 — Compare modules
Table: image module vs live hosts/edge—shared vs divergent. Fix any accidental fork.
Step 6 — Size pass
Disable docs; remove heavy packages; rebuild; note delta in MB and wall time.
Step 7 — Secret hygiene check
rg on image path—must be clean for publishable images. Record command + empty result.
Step 8 — Optional second format
ISO installer with same baseline for recovery story (feeds Day 88 thinking).
Step 9 — Write IMAGE.md
| Field | Value |
|---|---|
| Format | |
| Flake attr | |
| Modules shared | |
| Image-only modules | |
| Size | |
| Boot proof | path to log |
| Publish? | yes/no + why |
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| Wrong format name | Check generator docs for pin |
| Cannot SSH | keys, firewall, grow services, serial console |
| Image boots black screen | serial console -nographic; qemu-guest modules |
| Mutating store image | Use overlay qcow |
| 20GB “minimal” | Slim profile; no chrome; no full texlive |
| Secrets baked | Rebuild without; use cloud metadata / sops on first deploy |
| Generators nixpkgs drift | inputs.nixos-generators.inputs.nixpkgs.follows |
| Arch mismatch (build aarch on x86) | Cross or native builder (Day 54) |
| ISO vs installed system confusion | ISO boots live env; install path still needs nixos-install story |
Checkpoint
- Generator produces an artifact
- Artifact boots
- SSH or console access works
- Shared modules reused (table in
IMAGE.md)
- Secret hygiene checked
- Size recorded before/after slim pass
Commit
git add .
git commit -m "day62: nixos-generators image boot proof"Write three personal gotchas before continuing.
Tomorrow
Day 63 — Terraform boundary: TF provisions, Nix configures (sketch is enough).