Save products you love by clicking the heart icon.
Shifting cloud cost governance into the pipeline — Infracost cost estimates, OPA/Rego policy gates, OpenCost runtime monitoring, and the three-phase adoption path to hard-deny budgets.
A complete learning path from absolute beginner to production-ready DevOps professional. Start with Linux fundamentals, progress through containers and Kubernetes, add observability and automation, and finish with AI infrastructure. All open-source, vendor-neutral, with mapped certifications and production-ready stacks.
Nix is often presented as a radical departure: a whole new operating system, a new way to build software, a new mental model for everything. That framing scares teams away, because "switch everything to NixOS" sounds like a multi-quarter migration with no halfway point.
The reality is different. Nix is a ladder — you can climb one rung at a time, and every rung pays for itself before you decide whether to climb further. The "Nix spirit" is not a specific tool; it is a set of properties you can adopt incrementally:
This article defines six levels of adopting that spirit, from "pinned tools in a shell" to "immutable, self-healing appliances", and grounds each level in real deployments: a nine-node K3s cluster provisioned with OpenTofu and Ansible, an air-gapped three-node K3s cluster with Ceph storage, and a groupware stack (SOGo, OpenLDAP, Stalwart) whose container images are built with Nix.
The ladder is not a mandate to reach the top. Every rung pays for itself before you decide whether to climb further — the cheapest one (a pinned devShell) is also the one that fixes the most common daily pain.
The baseline most infrastructure starts from: SSH into a host, apt install things, edit config files by hand, hope the backup of the config file is recent. State lives on the machine, not in code.
The failure mode is drift. The person who provisioned the host left; the wiki page explaining the special cron job is gone; the monitoring alert for the service that "nobody knows who set up" gets muted. Audits become archaeology.
You know you are ready for Level 1 when any of these are true:
The first rung is the cheapest and the most immediately useful: use Nix to define what tools exist in your shell. A flake.nix with a devShell replaces the "install these 12 tools" section of your README with a single command — nix develop — that gives every contributor the exact same toolchain, on any Linux or macOS machine.
A real example from a Kubernetes operations repo:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.default = pkgs.mkShell {
packages = [
pkgs.kubectl
pkgs.kubernetes-helm
pkgs.helmfile
pkgs.ansible
pkgs.yq-go
];
};
});
}
This is not a toy example — it is the pattern used to pin the exact admin toolchain (kubectl 1.32.3, helm 3.15.3, helmfile 0.150.0, ansible 2.16.0, yq) for a multi-cluster deployment. The benefits, in practice:
flake.lock. Nobody sneaks in a minor-version CLI change that behaves differently in production.nix develop works on a laptop, a jump host behind an HTTP proxy, and a CI runner. In an air-gapped environment, the closure is either pre-fetched or served from a local cache — no curl | sh from the internet.What it costs: learning the flake syntax and a few Nix idioms. That is a weekend, not a quarter. When to stop here: if your pain is exclusively "tools differ between machines", this level already fixes it.
The second rung: use Nix to build the artifacts your deployment consumes — most commonly OCI container images. dockerTools.buildImage and buildLayeredImage produce images that are reproducible, layer-cached by store path, and free of the "pulled from Docker Hub last Tuesday" uncertainty.
The pattern that proved itself in practice is a flake per service, with the image as a package output and the same expression reused as a CI check:
{
description = "Nix flake for OpenLDAP container images";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/release-24.05";
};
outputs = { self, nixpkgs, ... }@inputs:
let
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
mkImage = system:
import ./images/openldap.nix {
inherit system;
pkgs = nixpkgs.legacyPackages.${system};
};
in {
packages = nixpkgs.lib.genAttrs supportedSystems (system: {
openldap = mkImage system;
default = mkImage system;
});
checks = nixpkgs.lib.genAttrs supportedSystems (system: {
default = mkImage system;
});
};
}
Three properties of this flake are worth copying:
genAttrs over supportedSystems derives x86_64 and aarch64 images from one expression — no separate Dockerfiles drifting apart. The same trick, via extendModules, derives environment variants from a single base configuration.checks means nix flake check in CI evaluates and builds it. If the expression breaks, CI breaks — before anything is pushed to a registry.sed commands in a Dockerfile. A groupware frontend that needed accessibility fixes (a dialog z-index bug, a placeholder contrast problem, a theme mismatch) ships as a patch applied during the build — the exact set of changes is visible in version control and reproducible forever.In an air-gapped cluster, this rung earns its keep immediately: images are built once, loaded into a local registry, and the nodes never need Docker Hub access at all. nix build + docker load + docker tag + push is the entire publish pipeline.
What it costs: a dockerTools learning curve and, initially, slower iteration than a Dockerfile. When to stop here: if your team owns a handful of images and the pain is "what exactly is in this image and who built it when".
The third rung is about the ecosystem around the builds: making Nix the shared substrate for how code is formatted, checked, and distributed across the team.
treefmt with nixfmt (formatter), statix (linter with autofix), and deadnix (dead-code removal) becomes the single source of truth for what "formatted Nix" means. One nix fmt on every repo, enforced in CI.nix flake check as the CI gate. Eval-only checks, image builds, and even full NixOS integration tests (testers.runNixOSTest, which boots a VM and asserts service-to-service behavior) run before anything merges.nix-serve, Attic, or harmonia, with signed store paths, let every node and CI runner pull pre-built artifacts instead of building them locally. A post-build-hook auto-uploads every new path. In an air-gapped network this is the difference between "the cluster rebuilds in minutes" and "the cluster rebuilds for a day".nix.buildMachines and nix.distributedBuilds.A subtle but powerful pattern at this level: Nix as a configuration generator. Rather than hand-writing Kubernetes manifests, the deployment generates them from Nix — a library with a k8s function turns typed Nix values into YAML. All 57 services of one deployment (from ArgoCD and monitoring down to the mail stack) are derived from one expression tree. The manifests are still YAML at the cluster, but they are generated YAML with a single origin, and regenerating them is a build step, not a manual editing session.
What it costs: infrastructure (a cache server, a build machine) and CI plumbing. When to stop here: when your images are built with Nix but your nodes and machines are still managed imperatively — Level 4 is a different project.
The fourth rung is the one most people picture when they hear "Nix": the OS itself is declared in a flake. /etc is generated from configuration; rollback is a boot menu entry; secrets are encrypted in the repo, not scattered across hosts.
The modern patterns, all of which are battle-tested by the ecosystem:
disko declares partitions, filesystems, and LUKS encryption in the same flake; nixos-anywhere installs a remote machine from that declaration over SSH, no ISO babysitting.impermanence or systemd StateDirectory=) survives. A compromised or corrupted machine returns to its declared state by rebooting.agenix and sops-nix encrypt secrets in the repo; systemd LoadCredential= hands them to services at runtime.nixos-rebuild per host remains for the single-machine case.Where does this leave a cluster that is already Kubernetes? The honest answer from experience: NixOS nodes and K8s workloads complement each other. The cluster's nodes become reproducible, self-healing, and patched in lockstep (one flake = one known-good node image), while the workloads continue to run in containers with their own lifecycle. Ansible still has a role for the bootstrap and for things Nix does not model well — but the drift-prone middle layer (hand-edited /etc, ad-hoc packages, manual sysctls) disappears.
What it costs: the steepest learning curve so far; NixOS has its own way of doing networking, users, and services. When to stop here: if your fleet is small and your nodes are already pets you rarely touch, the ROI may not be there yet.
The top rung: the machine stops being a machine and becomes an appliance. Everything below applies, plus:
systemd-sysupdate (or the nixos-appliance-ota-update pattern) keeps two slots; a failed boot falls back to the previous slot automatically.lanzaboote signs Unified Kernel Images; TPM-based disk encryption binds the disk to the hardware.At this level, "updating a server" is indistinguishable from "deploying new firmware". It is the pattern for edge devices, kiosks, and remote sites where no one can SSH in to fix a broken upgrade — but it is also a legitimate endgame for core infrastructure nodes.
To keep this honest, here is where the deployments behind this article's examples stand on the ladder:
| Layer | Level today | Notes |
|---|---|---|
| Admin tooling (kubectl, helm, helmfile, ansible) | 1 — pinned devShell | One flake, used from laptops and jump hosts |
| Mail/groupware images (SOGo, OpenLDAP 2.6.7, Stalwart) | 2 — Nix-built containers | Multi-arch flakes, checks in CI, local registry |
| K8s manifest generation | 3 — Nix-generated YAML | ~57 services derived from one expression tree |
CI gates (treefmt, nix flake check) | 3 — partial | Formatter and linting adopted; full check gate in progress |
| Binary cache for the air-gapped cluster | 3 — planned | Attic/nix-serve + post-build-hook, signed store paths |
| Cluster nodes (K3s on bare metal / Proxmox) | 0–1 — OpenTofu + Ansible | Fully parameterized, but imperative state on the nodes |
| Node OS | 4–5 — roadmap | disko, impermanence, A/B OTA as the target state |
The migration path, in order of value per unit of effort:
The ladder is not a mandate to reach the top. The right level depends on three questions:
Start at Level 1 this week — pin your toolchain in a devShell, commit the flake.lock, and make "works on my machine" a historical phrase. Everything above that is a series of small, individually sensible bets, not a revolution.
Click a rung to see what it gives you, what it costs, and when to stop climbing.
Cost: A weekend of flake syntax and Nix idioms.
Stop here if: When your pain is only 'tools differ between machines', this level already fixes it.
Answer honestly about today — the assessment tells you where you stand and which rung to climb next.
Reproducibility compounds. The devShell you pin this week is the same expression tree that will one day build your appliances — every rung you climb makes the next one cheaper.