NixOS: Stop Installing Packages, Start Declaring Outcomes
NixOS isn't a package manager with a config file—it's a system where the config file *is* the system. Stop fighting the imperative urge to mutate /etc and start declaring outcomes.
16 Mar 2026, 00:54 UTC

The imperative habit that breaks on NixOS
Most engineers approach NixOS like any other Linux distribution: they SSH in, run nix-env -iA nixpkgs.nginx, edit /etc/nginx/nginx.conf directly, and wonder why their changes vanish after nixos-rebuild switch. The friction isn't a bug—it's the system telling you that imperative mutations don't survive declarative reconciliation.
The core insight: NixOS doesn't manage packages. It manages a content-addressed store (/nix/store) where every derivation output is immutable and identified by a cryptographic hash of its complete build inputs. configuration.nix isn't a script; it's a specification that evaluates to a single system derivation. When you switch, the bootloader entry and /run/current-system symlink atomically flip to that derivation. Partial states never exist.
From "install this" to "this is the system I want"
In a traditional distro, you mutate state incrementally. In NixOS, you describe the target state and let the evaluator compute the closure. This means:
- No drift: two machines with identical
configuration.nixproduce bit-for-bit identical store paths (modulo hardware-specific derivations). - Rollback is free: the previous generation stays in the store and in the boot menu until garbage-collected.
- Binary caches substitute builds: if
hydra.nixos.orgor your private cache has the closure,nixos-rebuilddownloads instead of compiling.
The mental shift: stop asking "how do I install X?" and start asking "what does my services.x module need to look like?"
Worked example: a reproducible development container
Suppose you need a container that mirrors your production Node.js service—same runtime, same system libraries, and same openssl version. With Dockerfiles you'd pin base images and hope apt-get install stays deterministic. In NixOS you declare the container inline:
{ config, pkgs, ... }:
{
virtualisation.oci-containers.containers = {
api = {
image = pkgs.dockerTools.buildImage {
name = "my-api";
tag = "sha256-${pkgs.lib.version}";
contents = [ pkgs.nodejs_20 pkgs.openssl pkgs.ca-certificates ];
config = {
Cmd = [ "node" "server.js" ];
WorkingDir = "/app";
Env = [ "NODE_ENV=production" ];
};
copyToRoot = {
"/app/server.js" = ./server.js;
};
};
environment = {
DATABASE_URL = config.secrets.db-url;
};
};
};
}
Run sudo nixos-rebuild test to validate the closure without touching the bootloader. The resulting image is a single store path; docker load < $(nix-build -A config.virtualisation.oci-containers.containers.api.image) produces a tarball whose layers are deterministic because every layer is a store path. Change pkgs.nodejs_20 to pkgs.nodejs_22 and rebuild—the new image shares every unchanged layer via the store.
The trade-off: module merging isn't stable across major releases
NixOS modules use a merge system where multiple files can define the same option (e.g., services.nginx.virtualHosts). The merging semantics—lib.mkMerge, lib.mkForce, lib.mkDefault—are powerful but can change between major versions. The 23.11 → 24.05 transition altered how list options merge when mkForce appears in multiple modules. If you rely on undocumented merge precedence, a major upgrade can silently flip service ordering or drop overrides.
Mitigation: pin your channel (nixos-24.05), read the release notes before nixos-rebuild switch --upgrade, and keep a minimal configuration.nix that imports versioned module directories (e.g., ./modules/24.05/) so you can bisect regressions.
Verify before you commit
nixos-rebuild dry-build— evaluates the config, shows store paths that would be built or substituted, no side effects.nixos-rebuild test— activates the new generation in the current session (services restart, symlinks update) but doesn't update the bootloader. Revert withsystemctl isolate graphical.targetor reboot.sudo nixos-rebuild switch— makes the generation default. Afterward,ls -l /run/current-systemshows the exact store path;nix-store -q -r /run/current-systemlists the full closure for audit.
If test surprises you, the old generation is still the default in the boot menu. No rollback procedure needed—just select it at GRUB.
Start small, stay declarative
Pick one service you manage today (nginx, postgres, a custom systemd unit). Move its config into configuration.nix using the corresponding module options. Run nixos-rebuild test. When it works, delete the hand-edited files in /etc. Repeat. The store handles the rest.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.