Nix

What is Nix?

Nix is three things:

  1. A purely functional package manager — packages are built by functions that have no side effects. Same inputs always produce the same output.
  2. A purely functional language — used to describe how to build packages (the Nix expression language).
  3. 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/store is 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:

  1. Computes a hash from all the inputs.
  2. Creates a .drv file in /nix/store/ (the “build recipe”).
  3. 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 $out variable 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:

  1. Builds (or fetches from cache) the firefox derivation.
  2. Creates a new generation of your profile — a new symlink tree combining all installed packages.
  3. Points ~/.nix-profile to 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 or NIX_PATH.
  • Lock files: flake.lock pins all inputs to exact revisions.
  • Standardized structure: every flake has a flake.nix with 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 (or self): the previous package set (before the overlay).
  • final (or super): 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