Traditional Linux distributions follow an imperative model. You install packages, edit configuration files, and start services through a series of commands. Over time, the state of your system becomes the accumulated result of all these actions, often leading to "configuration drift" where two machines that started identically end up in different states.
NixOS takes a fundamentally different approach: it is a purely functional operating system. Your entire system configuration is a program written in the Nix expression language. When you run nixos-rebuild switch, Nix evaluates this program and produces a complete description of your system state. The system then activates into that state.
Think of traditional system administration as giving someone step-by-step directions to your house: "Turn left at the gas station, go three blocks, turn right at the oak tree..." If the oak tree gets cut down or the gas station closes, the directions fail.
NixOS is like giving someone your address: "123 Main Street." They use a map (the Nix evaluator) to determine the route. If the route changes, the map still gets them to the right place.
A pure function in mathematics always returns the same output for the same input. NixOS brings this concept to system configuration. Given the same configuration files and inputs (package versions), NixOS will produce the exact same system state, anywhere, anytime.
This is possible because:
1. Immutable store: All packages live in /nix/store with cryptographic hashes in their paths. A package at /nix/store/abc123-firefox will always be exactly that version of Firefox, forever.
2. Explicit dependencies: Packages declare exactly what they depend on. If Firefox needs libpng, that dependency is recorded and enforced.
3. No global state: Configuration doesn't mutate files in place; it creates new ones. Old versions remain available.
Flakes are a relatively recent addition to Nix (as of Nix 2.4) that formalize how Nix projects specify their dependencies. Think of a flake as a contract that says: "Given these exact inputs, I will produce these outputs."
A flake.nix file has two main sections:
Inputs are your dependencies. These might include:
Each input is pinned to a specific version via URL. When you run nix flake update, Nix fetches the latest version of each input and records the exact git commit hash in flake.lock. This lock file is what makes your configuration reproducible.
Outputs are what your flake produces. For a NixOS system configuration, the main output is nixosConfigurations—a set of machine configurations. Each entry in this set represents a complete computer that can be built.
Before flakes, Nix used environment variables and global configuration to determine where to find dependencies. This worked but had problems:
Flakes solve this by making dependencies explicit and version-locked. When you share your configuration (via git), the flake.lock file ensures whoever uses it gets exactly the same versions you had.
One crucial concept is how data flows from your flake into your configuration modules. When you define a NixOS system in your flake, you can pass a specialArgs attribute. This is a set of values that will be passed as arguments to every module in your configuration.
This matters because modules need access to your flake inputs. For example, if you import the ATProto NUR as an input, your configuration modules need to reference packages from it. By passing inputs (or specific inputs) via specialArgs, you make them available throughout your configuration.
Without this, you'd have to hardcode paths to external modules, breaking the purity and portability of your configuration.
A crucial distinction in NixOS is between when things happen:
Build time is when Nix evaluates your configuration and builds packages. This happens on the machine where you run nixos-rebuild. Nix downloads source code, compiles programs, and creates store paths. This can be done anywhere—even on your laptop for a server configuration.
Activation time is when the built configuration is applied to the actual system. This happens on the target machine. Activation scripts run, systemd units start, directories are created, and secrets are decrypted.
Understanding this distinction is crucial because:
/var/lib directories)For example, your PostgreSQL database is created at activation time, not build time. The Nix store is read-only and shared, but /var/lib/postgresql is writable and machine-specific.
Every time you run nixos-rebuild switch, NixOS creates a new generation. This is a complete system configuration that includes:
Generations are numbered sequentially. You can list them, compare them, and switch between them. If a new configuration breaks something, you can boot into a previous generation from the GRUB bootloader menu, or use nixos-rebuild switch --rollback to go back to the last working generation.
This makes NixOS remarkably safe to experiment with. The worst case is usually "reboot and select the previous generation." Your system is never truly broken because you always have working versions available.
The Nix expression language is a pure, lazy, functional language. You don't need to be a functional programming expert, but understanding a few concepts helps:
Everything is an expression: In Nix, everything evaluates to a value. There are no statements, only expressions. A configuration file is one big expression that evaluates to an attribute set (dictionary/map).
Attribute sets: The fundamental data structure is the attribute set, written as { key = value; }. These can be nested arbitrarily. Your entire NixOS configuration is one giant nested attribute set.
Functions: Functions are first-class values. A module is typically a function that takes an attribute set of arguments (like config, pkgs, lib) and returns configuration.
Let bindings: The let ... in ... syntax lets you define local variables. This is essential for avoiding repetition and making configurations readable.
String interpolation: You can embed expressions in strings with ${...}. This is how you build paths, commands, and configuration values dynamically.
Traditional configuration management tools (Ansible, Chef, Puppet) work by making changes to an existing system. Over time, the gap between your desired state and actual state grows. You end up with "snowflake servers"—each one slightly different.
NixOS avoids this because:
1. Complete specification: Your configuration describes the entire desired state, not just changes to apply.
2. No ordering problems: Traditional tools need to apply changes in the right order. NixOS just evaluates to a complete state.
3. Testability: You can build a configuration without activating it. This lets you catch errors before they affect a running system.
4. Atomic updates: The switch from one configuration to another is atomic. Either the whole new configuration activates, or nothing changes.
5. Easy replication: To set up a new server, you copy your configuration, change a few machine-specific settings, and rebuild. The new server will be identical (except for data).
As you work with NixOS, several mental models help:
Git for systems: Like git tracks versions of code, NixOS tracks versions of your entire system. Generations are like commits.
Functional programming: Your configuration is a pure function from inputs (nixpkgs version, your settings) to output (system state).
Build system: Nix is like Make or Bazel but for entire systems. It tracks dependencies and only rebuilds what changed.
Package manager: The Nix store is a content-addressed cache. If two packages need the same dependency, they share it automatically.
Understanding these foundations makes everything else in NixOS clearer. The complexity you encounter is usually just the intersection of these simple, powerful concepts.