Save products you love by clicking the heart icon.
Evidenzbasierte Testing-Praxis aus Produktions-Codebasen — property-basierte Invarianten, gezielte Fehlerinjektion, Contract-Tests, benchmark-verifizierte Performance und Qualitäts-Gates, die durchgesetzt werden, nicht nur versprochen.
A practical guide to DevOps certifications in 2026. Compare LPI DevOps Tools Engineer, CKAD, CKA, Terraform Associate, and AWS DevOps Engineer — which to choose, what they cover, how they map to real-world skills, and how they fit together in a career path from beginner to expert.
Nix has a reputation for being hard. That reputation comes from the wrong starting point: most tutorials begin with the Nix expression language or with NixOS, when the useful journey starts with a single command in a shell and only reaches the operating system after you have felt the reproducibility benefits three times.
This is the hands-on companion to the Nix Adoption Ladder — where that article explains levels of adopting the Nix spirit, this one is the concrete path: install, shells, flakes, packaging, containers, NixOS, and the production patterns that make a fleet reproducible. Every example is taken from patterns that run in real deployments: an admin toolchain pinned in a flake, container images for a mail stack, and an air-gapped cluster pulling from a self-hosted cache.
The standard multi-user installer is the right default on Linux and macOS:
sh <(curl -L https://nixos.org/nix/install) --daemon
The Determinate Systems installer (nix-installer) is the ergonomic alternative: idempotent, better uninstall, works on more platforms. In a container, docker run -it nixos/nix gives you an ephemeral playground in seconds — perfect for following along.
nix-shell -pnix-shell -p drops you into a temporary shell with packages added — without installing anything system-wide:
nix-shell -p git jq # shell with git and jq on PATH
nix-shell -p git --run "git --version" # run one command, exit
nix-shell -p git --pure # isolated: no inherited PATH
Stop and appreciate what just happened: two tools, pinned to a specific nixpkgs revision, available instantly, and gone when you exit. No apt install, no "which version does this box have?", no system pollution. This single command is the whole Nix pitch in miniature.
Ad-hoc -p is for exploration. The moment a project needs a reproducible toolchain, write it down:
# shell.nix
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShellNoCC {
packages = [ pkgs.git pkgs.jq ];
GREETING = "Hello";
shellHook = ''
echo "$GREETING — $(git --version)"
'';
}
nix-shell # enter the environment
For real projects, pin nixpkgs so "reproducible" means byte-for-byte, not "whatever my channel has today":
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/<sha256>.tar.gz") {} }:
pkgs.mkShell { packages = [ pkgs.kubectl pkgs.kubernetes-helm ]; }
Pair it with direnv and the environment loads itself the moment you cd into the project — the toolchain is now a property of the directory, not of the person.
The single highest-ROI Nix habit: pin the admin toolchain of every repo in a devShell. The reference deployment for this article pins kubectl, helm, helmfile, ansible, and yq to exact versions in a flake — the same nix develop works on a laptop, a jump host behind a proxy, and a CI runner.
Flakes are the current standard: a flake.nix at the repo root declares inputs (dependencies, pinned by content hash in flake.lock) and outputs (devShells, packages, checks, NixOS configurations). One file, one lockfile, one command for everything.
# flake.nix
{
description = "My project";
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.git pkgs.jq ];
};
packages.default = pkgs.hello; # nix build
checks.default = pkgs.hello; # nix flake check
formatter = pkgs.nixfmt; # nix fmt
});
}
The workflow:
nix develop # enter the devShell (replaces nix-shell)
nix build # build packages.default → result/
nix run # run it
nix flake check # evaluate AND build every check
nix flake update # bump inputs, rewrite flake.lock
Flake best practices that will save you real debugging time:
| Practice | Why |
|---|---|
Commit flake.lock | Pins every input to a content hash — the reproducibility contract |
let...in instead of rec | rec can recurse infinitely and is harder to reason about |
No with at the top of files | Ambiguous scoping |
No <nixpkgs> lookup paths in flakes | Impure — the lockfile exists to remove channels |
config = {}; overlays = []; when importing nixpkgs | Explicit purity, no accidental overrides |
lib.fileset for sources | Only the files you need enter the store; better caching |
git add new files before nix build | Flakes only see git-tracked files |
Run nix fmt via treefmt (nixfmt + statix + deadnix) | Formatting and dead-code linting, automated |
stdenv.mkDerivationPackaging is where Nix stops being a tool manager and becomes a build system. The core is stdenv.mkDerivation — a function that turns source + build steps into a store path whose hash covers every input:
{ stdenv, fetchFromGitHub, cmake }:
stdenv.mkDerivation {
pname = "myapp";
version = "1.0.0";
src = fetchFromGitHub {
owner = "me";
repo = "myapp";
rev = "v1.0.0";
hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
};
nativeBuildInputs = [ cmake ]; # build-time tools
buildInputs = [ ]; # runtime libraries
installPhase = ''
mkdir -p $out/bin
install -m755 myapp $out/bin/
'';
}
Two properties make this fundamentally different from a Makefile:
~/.cache, no system libraries, no "it worked on my machine". Everything the build can see is declared.Once packaged, the same derivation composes everywhere: as a devShell package, inside a container image, on a NixOS machine, or as a remote build via nix.buildMachines.
dockerToolsIf your deployment runs containers, Nix replaces your Dockerfiles:
# buildLayeredImage — each package becomes its own layer
dockerTools.buildLayeredImage {
name = "myapp";
tag = "latest";
contents = [ pkgs.bash pkgs.coreutils myapp ];
config = {
Cmd = [ "${myapp}/bin/myapp" ];
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
ExposedPorts = { "8080/tcp" = {}; };
};
created = "now"; # or a fixed timestamp for full reproducibility
}
nix build .#container
docker load < result
What you get instead of a Dockerfile:
buildLayeredImage).sed chain.genAttrs over ["x86_64-linux" "aarch64-linux"] derives both images; extendModules derives environment variants from one base. No per-arch Dockerfiles drifting apart.checks and nix flake check builds it in CI before anything reaches a registry.In an air-gapped cluster this pays for itself immediately: images are built once, loaded into a local registry, and the nodes never need Docker Hub at all. nix build → docker load → docker tag → push is the whole publish pipeline.
NixOS is the same model applied to the whole machine: /etc is generated from a declarative configuration, every change is a new generation, and rollback is a boot-menu entry.
# configuration.nix (or a flake's nixosConfigurations)
{ config, pkgs, ... }: {
boot.loader.systemd-boot.enable = true;
networking.hostName = "web-01";
services.nginx = {
enable = true;
virtualHosts."example.com" = {
root = "/var/www";
forceSSL = true;
enableACME = true;
};
};
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" ];
};
system.stateVersion = "24.11";
}
nixos-rebuild switch # apply and activate
nixos-rebuild boot # activate on next boot
nixos-rebuild switch --rollback # back to the previous generation
The operational difference is hard to overstate: "it worked yesterday" becomes a boot menu selection, not a restore from backup. Every nixos-rebuild switch creates a new generation; the bootloader keeps the last N. A botched upgrade costs one reboot.
For fleets, Colmena (or comin for the GitOps flavor) deploys many machines from one flake:
# hive.nix
{
meta = { nixpkgs = import nixpkgs { system = "x86_64-linux"; }; };
defaults = { pkgs, ... }: {
environment.systemPackages = [ pkgs.vim pkgs.wget ];
};
node-a = { name, ... }: {
networking.hostName = name;
deployment.targetHost = "node-a.example.com";
deployment.buildOnTarget = false; # build locally, copy the closure
};
}
colmena apply # all nodes, in parallel
colmena apply --on node-b # tag-based subset
Every node building nixpkgs from source is a waste of the universe. A self-hosted cache serves pre-built store paths; post-build-hook uploads every new path automatically.
| Tool | Storage | Signing | Fit |
|---|---|---|---|
| nix-serve | local store | on-the-fly | simplest, single machine |
| Attic | S3-compatible (dedup) | server-side | air-gap, multi-tenant, LRU GC |
| Harmonia | local store | on-the-fly | Rust, zstd, built-in TLS |
For the air-gapped reference cluster, Attic is the recommendation: its S3 backend can sit on the existing Ceph cluster (RGW), global dedup saves scarce bandwidth, and server-side signing means builders never hold the signing key. One services.atticd module on a cache node turns cluster rebuilds from a day into minutes.
The Nix store is world-readable. Secrets therefore never enter it:
.age files committed to git, decrypted at activation. The simplicity winner.LoadCredential= — the runtime complement: the unit receives the secret as a file under /run/credentials, no Nix involvement at all.# agenix
age.secrets.database-password = {
file = ./secrets/database-password.age;
owner = "mariadb";
mode = "0400";
};
diskodisko declares partitions, filesystems, and LUKS in the same flake as the OS; nixos-anywhere installs a remote machine from it over SSH:
disko.devices.disk.main = {
device = "/dev/sda";
type = "disk";
content = {
type = "gpt";
partitions = {
ESP = { size = "500M"; type = "EF00"; content = { type = "filesystem"; format = "vfat"; mountpoint = "/boot"; }; };
root = { size = "100%"; content = { type = "filesystem"; format = "ext4"; mountpoint = "/"; }; };
};
};
};
nixos-anywhere --flake .#node-a root@node-a # provisioned, from zero to NixOS
impermanence wipes the root filesystem on every boot; only explicitly declared state survives:
fileSystems."/" = { device = "none"; fsType = "tmpfs"; options = [ "defaults" "size=25%" "mode=755" ]; };
fileSystems."/persistent" = { device = "/dev/vg/root"; neededForBoot = true; fsType = "btrfs"; options = [ "subvol=persistent" ]; };
A compromised or corrupted machine returns to its declared state by rebooting. Combined with lanzaboote (UEFI Secure Boot) and A/B OTA updates, the machine stops being a machine and becomes an appliance.
testers.runNixOSTestThe ultimate reproducibility payoff: integration tests boot the exact configuration you will deploy, in QEMU VMs, and assert service-to-service behavior:
checks.integration = pkgs.testers.runNixOSTest {
nodes.machine = { ... }; # the NixOS config under test
testScript = ''
machine.wait_for_unit("nginx.service")
machine.wait_for_open_port(80)
machine.succeed("curl -f http://localhost/")
'';
};
nix flake check then runs: eval checks, image builds, and full VM integration tests — the same expression tree that CI tests is the tree that ships.
flake.lock.formatter output — formatting is a build step, not an argument.checks in every flake — CI gates via nix flake check.dockerTools images with patches as expressions, multi-arch via genAttrs.post-build-hook.LoadCredential= for runtime secrets.runNixOSTest for anything that must not break in production.Start today with nix-shell -p git jq, commit your first shell.nix tomorrow, and let the store paths do the arguing about reproducibility from then on.