Skip to main content

NixOS (2): Core Concepts — Declarative Configuration, Flakes and Rollback

·1247 words·6 mins
Jin Li
Author
Jin Li
Fate lies within the lightcone.
NixOS Series - This article is part of a series.
Part 2: This Article

Motivation
#

The previous post explained why I chose NixOS again now. This post answers a more basic question: how does NixOS actually work? If you have never used NixOS but know a traditional distribution like Fedora well, this post should give you the right mental model. Once these concepts are clear, the later posts about repository architecture and AI-assisted maintenance become much easier to read.

I will cover five blocks: declarative configuration, content-addressed /nix/store, nixpkgs, flakes and flake.lock, and generations and rollback. Each is contrasted with how Fedora does the same thing.

Prerequisites
#

  • Basic Linux experience: installing software (dnf), editing configuration files under /etc, restarting services
  • No Nix language knowledge required; this post uses minimal examples

Declarative Configuration: Describe “What”, Not “How”
#

Traditional distributions are imperative. To run Tailscale on Fedora, you install the package, edit configuration files under /etc, make sure the firewall allows the port, and enable plus start the systemd service. Every step is an action, and the machine’s final state is the historical accumulation of actions you performed. Years later it is hard to say “what state this machine is actually in” — it depends on all historical operations stacked together, most of which left no record.

NixOS is declarative: you describe in one configuration what state the system should be in, not how to get there. A NixOS configuration is an attribute set, not a sequence of commands. For example:

1
services.tailscale.enable = true;

This line means “the system should contain and enable the Tailscale service”. Which packages are needed, which files must exist, which systemd unit to run, whether the firewall needs a hole — all of that is derived automatically by NixOS at build time; you do not write it. Another example:

1
networking.firewall.allowedTCPPorts = [ 22 ];

means “the firewall should allow TCP port 22”; NixOS generates the corresponding nftables rules from it.

The direct consequence: the whole state of a system can be described completely by one (or a few) files. Put that file in Git and you have a single source of truth for the system. Clone the repository on a new machine, build once, and you get an identical system — that is what “reproducible” means.

/nix/store: Content-Addressed, Immutable Storage
#

Everything installed by NixOS lives in /nix/store. Each path there looks like:

1
/nix/store/1rkkwlqg6ym07qmwyb6jw30z553xj96r-nixos-system-surf-26.05.20260804.04607e1

The hash prefix is computed from the package’s build inputs (source code, dependencies, build scripts). Identical inputs always produce the identical hash, and therefore the identical output — this is content addressing. It gives three properties:

  • Immutability: files in /nix/store are never modified in place. Upgrading a package does not overwrite the old version; it installs a new store path and repoints the system to it. The old version stays on disk and can be pointed back to at any time.
  • Sharing: two machines that build from identical inputs get identical hashes, so they can reuse each other’s store paths (or even download them from a binary cache instead of compiling).
  • No dependency hell: each package carries its exact dependency versions; two programs depending on different versions of the same library do not conflict.

The system itself is also a “package” in /nix/store — a directory called nixos-system-<hostname>. Switching configuration means repointing that directory’s symlink to a new build result: atomic and instant.

nixpkgs: The Huge Package Collection
#

nixpkgs is the NixOS package collection, containing tens of thousands of packages; each one is a Nix expression (a derivation) describing how to build it from source. A few points worth noting:

  • Channels and branches: nixpkgs updates in a rolling fashion. We pin ours to the stable nixos-26.05 release branch instead of master, for predictable stability.
  • Snapshots: every build “freezes” nixpkgs at some moment. Building the same configuration on different dates may yield different package versions — unless you lock it down with flake.lock (next section).

Flakes and flake.lock: Locking Down Every Input
#

A flake is a standardized mechanism for declaring a project’s inputs and outputs. Our entry point is flake.nix, which does two things:

  1. Declares inputs — which external repositories this project depends on, and at which versions:
1
2
3
4
5
6
7
inputs = {
  nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  nixos-hardware.url = "github:NixOS/nixos-hardware/master";
  home-manager.url = "github:nix-community/home-manager/release-26.05";
  sops-nix.url = "github:Mic92/sops-nix";
  # ... plus oh-my-rime, lazyvim-starter, opencode, and more
};
  1. Declares outputs — what to build. For us: the system configuration of each of the three machines, under nixosConfigurations:
1
2
3
4
5
6
nixosConfigurations = {
  surf = surfacePro6;
  "surface-pro-6" = surfacePro6;
  nixos = surfacePro6;   # compatibility alias for the old name
  inherit halo t460s;
};

Note that in the --flake /path#<name> syntax, the part after # selects which output to build. For example:

1
sudo nixos-rebuild switch --flake /home/lijin/nixos-config#surface-pro-6

Here #surface-pro-6 means “build this repository for the Surface Pro 6 machine”.

flake.lock is the companion lock file recording each input’s exact commit and hash. It must be committed to Git. With it, anyone building this repository at any time uses exactly the same versions of nixpkgs, home-manager, etc. — that is the source of reproducibility across machines and across time. To deliberately upgrade dependencies, run nix flake update; it rewrites flake.lock, and you review the diff before rebuilding.

Generations and Rollback: Every Change Is Reversible
#

Every nixos-rebuild switch produces a new generation; old generations stay on disk as rollback targets. This gives NixOS an ability traditional distributions lack: any configuration change is reversible.

  • sudo nixos-rebuild test --flake .#<host>: activates the new configuration temporarily, only for the current boot (a reboot returns to the old system). Good for a first trial.
  • sudo nixos-rebuild switch --flake .#<host>: activates it properly, creating a new generation as the default boot entry.
  • sudo nixos-rebuild switch --rollback: revert to the previous generation.
  • sudo nixos-rebuild list-generations: list rollback targets that still exist.
  • At boot time you can also pick an older generation directly in the systemd-boot menu.

Combined with /nix/store immutability, “switching systems” and “rolling back systems” are symlink-level operations: seconds, no data loss. This is why we dared to iterate 174 generations in a month — the cost of trial and error is caught by the system itself.

Note: nix-collect-garbage --delete-older-than 7d deletes old generations’ store paths, so they are no longer usable rollback targets. But the “per-generation release notes” we keep in Git are a historical record and remain even after their generation links have been garbage-collected (more on that in the repository architecture post).

How These Concepts Support Our Three Machines
#

Chaining the concepts together gives our overall approach:

MechanismRole in our setup
Declarative configurationThe state of all three machines is fully described by Nix files under modules/ + hosts/<host>/
/nix/storeEvery switch is atomic; old generations remain as rollback targets
nixpkgs (pinned to 26.05)A stable, reproducible source of packages
flake + flake.lockOne repository manages three machines; dependency versions locked, consistent across hosts
Generations and rollbackEvery change is reversible — enabling high-frequency iteration and bold AI edits

The next post looks at how the repository is actually organized: directory layout, the split between shared modules and host modules, the day-to-day change workflow, and the per-generation release notes we keep as a historical archive.

Related Posts#

NixOS Series - This article is part of a series.
Part 2: This Article