Nix
What is Nix?
Nix is three things:
- A purely functional package manager — packages are built by functions that have no side effects. Same inputs always produce the same output.
- A purely functional language — used to describe how to build packages (the Nix expression language).
- NixOS — a Linux distribution built entirely on Nix, where the whole OS is configured declaratively.
The core insight: builds are pure functions. A package is the result of a function that takes exact dependencies as inputs and produces a build output. If the inputs don’t change, the output is always identical (reproducibility). There is no global state, no implicit dependencies.
The Nix Store
Everything Nix builds goes into /nix/store. Every item is stored at a path like:
/nix/store/<hash>-<name>-<version>
Example:
/nix/store/b6gvzjyb2pg0kjfwrjmg1vfhh54ad73z-firefox-130.0
The hash is computed from all inputs to the build (source code, dependencies, build script, compiler flags, etc.). This means:
- Two packages with different dependencies get different hashes, even if they have the same name and version.
- If you change anything — even a single compiler flag — you get a new hash and a new store path.
- Multiple versions of the same package coexist peacefully (no “dependency hell”).
- Packages are immutable once built. Nothing in
/nix/storeis ever modified in place.
/nix/store/
├── abc123-glibc-2.38/
│ ├── lib/
│ │ └── libc.so.6
│ └── ...
├── def456-gcc-13.2.0/
│ ├── bin/
│ │ └── gcc
│ └── ...
├── ghi789-hello-2.12.1/
│ ├── bin/
│ │ └── hello # Dynamically linked to abc123-glibc
│ └── ...
└── ...
Binaries in the store are patched to reference their exact dependencies by store path (via RPATH or wrappers), so there is zero ambiguity about which libc or which openssl a program uses.
The Nix Language
Nix has its own lazy, purely functional language. It is not a general-purpose language — it exists solely to describe package builds and system configurations.
Primitive Types
# Strings
"hello world"
''
multi-line
string
''
# Integers
42
# Floats (rarely used)
3.14
# Booleans
true
false
# Null
null
# Paths (a distinct type from strings!)
./relative/path
/absolute/path
Let Bindings
let
x = 1;
y = 2;
in
x + y
# => 3
let ... in ... introduces local variables. Variables are immutable — once bound, they cannot be reassigned.
Attribute Sets (the core data structure)
Attribute sets are key-value maps. They are the most important data structure in Nix.
# Basic attrset
{ name = "firefox"; version = "130.0"; }
# Nested access
let
pkg = { name = "firefox"; meta = { license = "MPL"; }; };
in
pkg.meta.license
# => "MPL"
# Recursive attrsets (attributes can reference each other)
rec {
x = 1;
y = x + 1;
}
# => { x = 1; y = 2; }
Functions
All functions in Nix are single-argument. Multi-argument functions are done via currying or by taking an attrset as argument.
# Single-argument function
x: x + 1
# Calling it
(x: x + 1) 5
# => 6
# Curried (multi-argument)
x: y: x + y
# Call: (x: y: x + y) 3 4 => 7
# Attrset argument with destructuring (most common pattern)
{ name, version }:
"${name}-${version}"
# With default values
{ name, version ? "1.0" }:
"${name}-${version}"
# With extra attributes allowed (... means "ignore the rest")
{ name, version, ... }:
"${name}-${version}"
String Interpolation
let
name = "world";
in
"hello ${name}"
# => "hello world"
Works with any expression: "result is ${toString (1 + 2)}".
Lists
[ 1 2 3 "hello" true ]
# Note: no commas between elements
# Concatenation
[ 1 2 ] ++ [ 3 4 ]
# => [ 1 2 3 4 ]
Conditionals
if x > 0 then "positive" else "non-positive"
There is no if without else — it’s an expression, not a statement.
with Expression
Brings an attrset’s attributes into scope:
let
pkgs = { gcc = "gcc-13"; gdb = "gdb-14"; };
in
with pkgs; [ gcc gdb ]
# => [ "gcc-13" "gdb-14" ]
inherit Keyword
Shorthand for x = x; inside attrsets:
let
name = "hello";
version = "2.12";
in {
inherit name version;
# equivalent to: name = name; version = version;
}
import
Loads and evaluates another .nix file:
# Loads ./foo.nix and calls the result (if it's a function) with the argument
import ./foo.nix { system = "x86_64-linux"; }
builtins
Nix has a set of built-in functions: builtins.map, builtins.filter, builtins.readFile, builtins.toJSON, builtins.fetchurl, etc.
builtins.map (x: x * 2) [ 1 2 3 ]
# => [ 2 4 6 ]
builtins.filter (x: x > 2) [ 1 2 3 4 5 ]
# => [ 3 4 5 ]
builtins.attrNames { a = 1; b = 2; }
# => [ "a" "b" ]
Derivations — The Core Concept
A derivation is the central concept of Nix. It is a precise description of how to build something. Every package in Nix is a derivation.
A derivation specifies:
- What to build (source code, patches)
- How to build it (build script / builder)
- What to build it with (dependencies — other derivations)
- For which platform (system architecture)
The Low-Level derivation Builtin
derivation {
name = "hello";
system = "x86_64-linux";
builder = "/bin/sh";
args = [ "-c" "echo hello > $out" ];
}
When Nix evaluates this, it:
- Computes a hash from all the inputs.
- Creates a
.drvfile in/nix/store/(the “build recipe”). - When you build it (
nix-build), Nix executes the builder in a sandboxed environment with:- No network access (by default)
- No access to the host filesystem (except explicitly declared inputs)
- The
$outvariable set to the output store path
mkDerivation — The Practical Wrapper
Nobody uses the raw derivation builtin directly. Instead, stdenv.mkDerivation from Nixpkgs provides a high-level wrapper with phases:
{ pkgs ? import <nixpkgs> {} }:
pkgs.stdenv.mkDerivation {
pname = "hello";
version = "2.12.1";
src = pkgs.fetchurl {
url = "https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz";
sha256 = "sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=";
};
buildInputs = [ pkgs.glibc ];
# These are the default phases (you can override any of them):
# unpackPhase -> extracts the source
# patchPhase -> applies patches
# configurePhase -> runs ./configure
# buildPhase -> runs make
# installPhase -> runs make install
# fixupPhase -> patches RPATHs, strips binaries, etc.
}
Build Phases in Detail
unpackPhase Extracts src tarball/zip/etc.
patchPhase Applies patches from `patches = [ ./fix.patch ];`
configurePhase Runs ./configure --prefix=$out (or cmake, meson, etc.)
buildPhase Runs make (or the equivalent)
checkPhase Runs make check (if doCheck = true)
installPhase Runs make install DESTDIR=$out
fixupPhase Patches ELF binaries (RPATH), wraps scripts, etc.
You can override any phase:
pkgs.stdenv.mkDerivation {
pname = "myapp";
version = "1.0";
src = ./.;
buildPhase = ''
gcc -o myapp main.c
'';
installPhase = ''
mkdir -p $out/bin
cp myapp $out/bin/
'';
}
The .drv File
When Nix evaluates a derivation, it writes a .drv file to the store. You can inspect it:
nix show-derivation /nix/store/xxxxx-hello-2.12.1.drv
It’s a JSON-like structure listing every input (store paths of dependencies), every output, the builder command, environment variables, etc. This is the complete, deterministic build plan.
Input-Addressed vs Content-Addressed
- Input-addressed (default): the output hash is computed from the inputs. If you change any input, the output path changes even if the actual output binary is identical.
- Content-addressed (experimental,
__contentAddressed = true): the output hash is computed from the actual output content. Identical outputs get the same store path regardless of how they were built.
Nixpkgs
Nixpkgs is the package repository — a giant Git repo containing ~100,000+ package definitions, all written in Nix.
Structure:
nixpkgs/
├── pkgs/
│ ├── applications/
│ │ └── networking/
│ │ └── firefox/
│ │ └── default.nix
│ ├── development/
│ │ └── compilers/
│ │ └── gcc/
│ │ └── default.nix
│ └── ...
├── lib/ # Utility functions (lib.mkOption, lib.types, etc.)
├── nixos/ # NixOS module system
│ └── modules/
│ ├── services/
│ │ └── nginx.nix
│ └── ...
└── default.nix # Entry point
You reference it with:
let
pkgs = import <nixpkgs> {};
in
pkgs.firefox
Or with a pinned version (Flakes or fetchTarball):
let
pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/<commit>.tar.gz") {};
in
pkgs.firefox
Profiles and Environments
A profile is a symlink tree that points into the store. When you nix-env -i firefox, Nix:
- Builds (or fetches from cache) the firefox derivation.
- Creates a new generation of your profile — a new symlink tree combining all installed packages.
- Points
~/.nix-profileto the new generation.
~/.nix-profile -> /nix/var/nix/profiles/per-user/guillaume/profile
/nix/var/nix/profiles/per-user/guillaume/profile -> profile-42-link
profile-42-link -> /nix/store/xxxx-user-environment/
bin/
firefox -> /nix/store/yyyy-firefox-130/bin/firefox
git -> /nix/store/zzzz-git-2.43/bin/git
You can rollback instantly because old generations are still in the store:
nix-env --rollback # Go back to generation 41
nix-env --list-generations
Nix Shell and nix develop
nix-shell (legacy)
Creates a temporary shell with specific packages available:
# Ad-hoc: get Python and curl in a temporary shell
nix-shell -p python3 curl
# From a shell.nix file:
nix-shell
shell.nix example:
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = [
pkgs.python3
pkgs.poetry
pkgs.gcc
];
shellHook = ''
echo "Dev environment loaded"
export MY_VAR="hello"
'';
}
nix develop (Flakes)
The modern equivalent using flakes:
nix develop # Uses flake.nix in current directory
Flakes
Flakes are the modern way to structure Nix projects. They provide:
- Hermetic evaluation: no reliance on
<nixpkgs>channel orNIX_PATH. - Lock files:
flake.lockpins all inputs to exact revisions. - Standardized structure: every flake has a
flake.nixwith well-known outputs.
flake.nix Structure
{
description = "My project";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
# A buildable package
packages.default = pkgs.stdenv.mkDerivation {
pname = "myapp";
version = "0.1.0";
src = ./.;
buildPhase = "gcc -o myapp main.c";
installPhase = "mkdir -p $out/bin && cp myapp $out/bin/";
};
# A dev shell
devShells.default = pkgs.mkShell {
buildInputs = [ pkgs.gcc pkgs.gnumake ];
};
# A NixOS module, a container, an overlay... anything
}
);
}
flake.lock
Auto-generated. Pins every input to a specific git revision + hash:
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1706091478,
"narHash": "sha256-xxxxxxxxx...",
"rev": "abc123...",
"type": "github"
}
}
}
}
Flake Commands
nix build # Build the default package
nix run # Build and run the default package
nix develop # Enter the dev shell
nix flake update # Update all inputs (rewrite flake.lock)
nix flake lock --update-input nixpkgs # Update only nixpkgs
nix flake show # Show all outputs of the flake
nix flake check # Run checks
Overlays
An overlay lets you modify or extend Nixpkgs. It’s a function that takes two arguments (final, prev) and returns an attrset of overrides:
final: prev: {
# Override an existing package
firefox = prev.firefox.overrideAttrs (old: {
patches = old.patches ++ [ ./my-fix.patch ];
});
# Add a new package
myTool = final.callPackage ./my-tool.nix {};
}
prev(orself): the previous package set (before the overlay).final(orsuper): the final package set (after all overlays are applied) — use this for dependencies to avoid infinite recursion.
Apply an overlay:
import nixpkgs {
overlays = [ myOverlay ];
}
The NixOS Module System
NixOS is configured via modules — Nix files that declare options and their implementations.
Basic NixOS Configuration
# /etc/nixos/configuration.nix
{ config, pkgs, ... }:
{
# Boot loader
boot.loader.systemd-boot.enable = true;
# Networking
networking.hostName = "mybox";
networking.firewall.allowedTCPPorts = [ 80 443 ];
# Services
services.nginx.enable = true;
services.nginx.virtualHosts."example.com" = {
root = "/var/www";
enableACME = true;
forceSSL = true;
};
# Users
users.users.guillaume = {
isNormalUser = true;
extraGroups = [ "wheel" "docker" ];
shell = pkgs.zsh;
};
# Packages
environment.systemPackages = with pkgs; [
vim git curl htop
];
system.stateVersion = "24.05";
}
Apply with:
sudo nixos-rebuild switch
This atomically builds the entire system, creates a new generation, and switches to it. You can always roll back.
Writing a NixOS Module
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.myApp;
in {
options.services.myApp = {
enable = mkEnableOption "my application";
port = mkOption {
type = types.port;
default = 8080;
description = "Port to listen on";
};
package = mkOption {
type = types.package;
default = pkgs.myApp;
description = "The myApp package to use";
};
};
config = mkIf cfg.enable {
systemd.services.myApp = {
description = "My Application";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/myapp --port ${toString cfg.port}";
DynamicUser = true;
Restart = "always";
};
};
networking.firewall.allowedTCPPorts = [ cfg.port ];
};
}
Garbage Collection
Since nothing in /nix/store is ever modified, unused packages accumulate. Clean up with:
# Remove old generations and collect garbage
nix-collect-garbage -d
# Remove generations older than 30 days
nix-collect-garbage --delete-older-than 30d
# Just see what would be deleted
nix-store --gc --print-dead
A store path is “alive” if it is reachable from any GC root (current profile generations, current system generation, running builds, etc.).
Closures
A closure is a package plus all of its runtime dependencies, recursively. When you install a package, Nix installs its entire closure.
# See the closure of a package
nix-store -qR $(which firefox)
# See the closure size
nix path-info -Sh $(which firefox)
This is what makes Nix deployments complete — you can copy a closure to another machine and it will work, because nothing is missing.
Binary Cache
Building everything from source would be slow. Nix uses binary caches (substituters) to download pre-built packages:
cache.nixos.org— the official cache (run by the NixOS Foundation)- You can set up your own with Cachix or Attic
When Nix needs to build a derivation, it first computes the output hash and checks if that exact hash exists in the cache. If yes, it downloads instead of building. This works because builds are deterministic: same inputs = same hash = same output.
Common Patterns
callPackage
The standard way to call package definitions in Nixpkgs:
# my-tool.nix
{ lib, stdenv, fetchurl, openssl }: # <-- declares dependencies
stdenv.mkDerivation {
pname = "my-tool";
version = "1.0";
src = fetchurl { ... };
buildInputs = [ openssl ];
}
# In your flake or overlay:
myTool = pkgs.callPackage ./my-tool.nix {};
# callPackage auto-fills arguments from pkgs
# You can override: pkgs.callPackage ./my-tool.nix { openssl = pkgs.openssl_3; }
override and overrideAttrs
# Override function arguments (from callPackage)
pkgs.myTool.override {
openssl = pkgs.libressl;
}
# Override derivation attributes
pkgs.myTool.overrideAttrs (old: {
version = "2.0";
src = fetchurl { ... };
patches = old.patches ++ [ ./extra.patch ];
})
Docker Images with Nix
pkgs.dockerTools.buildImage {
name = "my-app";
tag = "latest";
copyToRoot = pkgs.buildEnv {
name = "image-root";
paths = [ pkgs.coreutils myApp ];
pathsToLink = [ "/bin" ];
};
config = {
Cmd = [ "/bin/myapp" ];
ExposedPorts = { "8080/tcp" = {}; };
};
}
This produces minimal images (no base distro layer, only exactly what you need).
Mental Model Summary
┌─────────────────────────────────────────────────┐
│ You write │
│ │
│ .nix files (language) │
│ │ │
│ ▼ │
│ Derivations (build recipes) │
│ │ │
│ ▼ │
│ /nix/store/<hash>-<name> (immutable outputs) │
│ │ │
│ ▼ │
│ Profiles / Environments (symlink trees) │
│ │ │
│ ▼ │
│ Your running system │
└─────────────────────────────────────────────────┘
The key properties that emerge:
- Reproducibility: same inputs → same output, always
- Atomicity: system changes are all-or-nothing (symlink swap)
- Rollbacks: old generations are still in the store, one command to switch back
- Multi-version coexistence: different packages can depend on different versions of the same library
- No dependency hell: every package has its exact dependencies encoded in its store path
Useful Commands Cheat Sheet
# Search packages
nix search nixpkgs firefox
# Run a package without installing
nix run nixpkgs#cowsay -- "hello"
# Temporary shell with packages
nix shell nixpkgs#python3 nixpkgs#curl
# Build a flake
nix build .#myPackage
# Enter dev shell
nix develop
# Show the derivation of a package
nix show-derivation nixpkgs#hello
# Show dependency tree
nix-store -q --tree $(nix path-info nixpkgs#hello)
# Check what's keeping a store path alive
nix-store --query --roots /nix/store/xxx-hello
# REPL (interactive Nix evaluator)
nix repl
> :l <nixpkgs>
> lib.version
> hello.meta
Further Resources
- Nix Pills — step-by-step tutorial series, best way to deeply understand Nix
- nix.dev — official learning resource
- Nixpkgs manual — reference for packaging
- NixOS manual — reference for NixOS configuration
- Zero to Nix — modern beginner-friendly introduction
- Nix Flakes — flake documentation