You are viewing a preview of this lesson. Sign in to start learning
Back to Hermetic Builds

Nix Mental Model

Master Nix's functional approach: pure functions, derivations, and the Nix store.

Nix Mental Model

Master the Nix mental model with free flashcards and spaced repetition practice. This lesson covers functional package management, the Nix store structure, and derivation buildingβ€”essential concepts for creating hermetic builds that are reproducible across different environments.

Welcome to the Nix Mental Model 🎯

Nix is not just another package managerβ€”it's a fundamentally different approach to building and managing software. Understanding Nix requires shifting from imperative thinking ("install this package") to functional thinking ("describe what the system should look like"). This mental model is crucial for mastering hermetic builds, where every input is tracked and every output is reproducible.

Core Concepts πŸ’‘

The Nix Store: Immutable Build Artifacts

At the heart of Nix is the Nix store, typically located at /nix/store. Think of it as a content-addressed filesystem where:

  • Every package lives in its own isolated directory
  • Paths are named with a cryptographic hash of all inputs
  • Nothing is ever modified in placeβ€”only new versions are added
  • Multiple versions of the same software coexist peacefully
/nix/store/
  β”œβ”€β”€ a5b8c9d7...-hello-2.10/
  β”‚   β”œβ”€β”€ bin/
  β”‚   β”‚   └── hello
  β”‚   └── share/
  β”œβ”€β”€ f3e2d1c0...-python-3.11.2/
  β”‚   β”œβ”€β”€ bin/
  β”‚   β”‚   └── python3
  β”‚   └── lib/
  └── 9z8y7x6w...-bash-5.2/
      β”œβ”€β”€ bin/
      β”‚   └── bash
      └── share/

Why the hash? The hash at the beginning (like a5b8c9d7...) is computed from:

  • Source code inputs
  • Build script
  • Compiler version
  • All dependencies
  • Build flags and environment

If ANY of these change, you get a different hash β†’ a different path β†’ a different package. This is how Nix achieves bit-for-bit reproducibility.

πŸ’‘ Mental Model Tip: Think of the Nix store like Git for binaries. Each "commit" (build) has a unique hash, and you can have multiple "branches" (versions) simultaneously.

Derivations: Build Recipes as Data πŸ“‹

A derivation is Nix's term for a build recipe. Unlike traditional build scripts that are executed immediately, derivations are:

  1. Pure data structures describing how to build something
  2. Lazyβ€”they don't execute until you explicitly build them
  3. Composableβ€”derivations can reference other derivations as dependencies
Derivation Structure:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         Derivation (.drv)           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ β€’ Name: "myapp-1.0"                 β”‚
β”‚ β€’ Builder: /nix/store/.../bash      β”‚
β”‚ β€’ Build script: ./builder.sh        β”‚
β”‚ β€’ Sources: ./src                    β”‚
β”‚ β€’ Dependencies:                     β”‚
β”‚   - /nix/store/.../gcc              β”‚
β”‚   - /nix/store/.../glibc            β”‚
β”‚   - /nix/store/.../openssl          β”‚
β”‚ β€’ Environment variables             β”‚
β”‚ β€’ Output path: /nix/store/.../...   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         ↓
    (Build Time)
         β”‚
         ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚      Output in /nix/store           β”‚
β”‚   /nix/store/xyz...-myapp-1.0/      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Functional Purity: No Side Effects πŸ”’

Nix builds are purely functionalβ€”like mathematical functions:

f(inputs) = output

Same inputs β†’ Same output. Always.

This means during a build:

  • ❌ No network access (after sources are fetched)
  • ❌ No reading from /usr or /opt or system paths
  • ❌ No accessing the current time or random numbers
  • ❌ No writing outside the build directory
  • βœ… Only declared inputs are visible
  • βœ… Builds run in isolated sandboxes

⚠️ Common Misconception: "Pure" doesn't mean the build process is simpleβ€”it means the process is deterministic and isolated.

The Dependency Graph: DAG All the Way Down πŸ“Š

Nix represents all dependencies as a Directed Acyclic Graph (DAG). Each package is a node, and edges represent "depends on" relationships.

Dependency Graph Example:

    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚   myapp     β”‚
    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
           β”‚
      β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”
      ↓         ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  python β”‚  β”‚ openssl  β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
     β”‚             β”‚
     ↓             ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  glibc  β”‚  β”‚  zlib    β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
     β”‚             β”‚
     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
            ↓
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚  gcc     β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Key Properties:

  • Acyclic: No circular dependencies (A depends on B, B depends on C, C depends on A)
  • Explicit: Every dependency must be declared
  • Shared: If two packages need openssl, they use the same /nix/store path
  • Parallel: Independent branches can build simultaneously

Lazy Evaluation: Build Only What's Needed ⚑

Nix uses lazy evaluation inherited from the Nix expression language:

## This defines a build, but doesn't execute it
let
  myPackage = derivation {
    name = "myapp";
    builder = ./builder.sh;
    src = ./src;
  };
in
  myPackage  # Only evaluated when you actually use it

Builds happen on-demand when you:

  • Run nix-build
  • Install with nix-env -i
  • Activate a NixOS configuration

πŸ’‘ Mental Model: Nix expressions are like promisesβ€”they describe what could be built, not what has been built.

Closures: Complete Dependency Sets πŸ“¦

A closure is the set of a package plus ALL its runtime dependenciesβ€”everything needed to run it.

Closure of "myapp":

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Runtime Closure                 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  β€’ /nix/store/.../myapp          β”‚
β”‚  β€’ /nix/store/.../python         β”‚
β”‚  β€’ /nix/store/.../openssl        β”‚
β”‚  β€’ /nix/store/.../glibc          β”‚
β”‚  β€’ /nix/store/.../zlib           β”‚
β”‚  β€’ /nix/store/.../libffi         β”‚
β”‚  β€’ /nix/store/.../ncurses        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Copy this entire set β†’ works anywhere!

Closures are why Nix deployments are so reliable:

## Copy entire closure to another machine
nix-copy-closure --to user@server /nix/store/...-myapp

## Everything needed to run myapp is now there!

Profiles and Generations: Time Travel for Packages ⏰

Nix doesn't overwrite packagesβ€”it creates generations:

Profile Timeline:

~/.nix-profile β†’ /nix/var/nix/profiles/per-user/alice/profile
                         |
                         ↓
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚                                 β”‚
   Generation 1    Generation 2    Generation 3 (current)
   2024-01-10      2024-01-15      2024-01-20
        β”‚                β”‚                β”‚
        ↓                ↓                ↓
    [firefox-120]    [firefox-120]    [firefox-121]
    [python-3.10]    [python-3.11]    [python-3.11]
    [vim-9.0]        [vim-9.0]        [neovim-0.9]
                         ↑
                    (can rollback to)

Rollback is instantaneous:

## Oops, new package broke something
nix-env --rollback

## Or jump to specific generation
nix-env --switch-generation 2

🧠 Mnemonic: Think "Git for your system state"β€”each generation is like a commit you can checkout.

Detailed Examples πŸ”§

Example 1: Understanding Store Paths

Let's break down a real Nix store path:

/nix/store/4zmn3qw7afk3d8b5h9m2v1l0xjc6nfp8-bash-5.2.15
           β”‚                                  β”‚  β”‚    β”‚
           β”‚                                  β”‚  β”‚    └─ Version
           β”‚                                  β”‚  └───── Package name
           └──────────────────────────────────┴──────── Hash (32 chars)

The hash includes:

{
  # Build inputs
  src = fetchurl { url = "https://ftp.gnu.org/bash-5.2.15.tar.gz"; };
  
  # Build-time dependencies
  buildInputs = [ gcc glibc makeWrapper ];
  
  # Build script
  builder = ./builder.sh;
  
  # Configuration flags
  configureFlags = [ "--enable-readline" "--with-curses" ];
  
  # System architecture
  system = "x86_64-linux";
}

Change ANY of these β†’ different hash β†’ different path β†’ different package.

Example 2: Building a Simple Derivation

Here's how to create a minimal Nix package:

## hello.nix
{ pkgs ? import <nixpkgs> {} }:

pkgs.stdenv.mkDerivation {
  pname = "hello-custom";
  version = "1.0";
  
  src = ./src;  # Local source directory
  
  buildPhase = ''
    gcc -o hello hello.c
  '';
  
  installPhase = ''
    mkdir -p $out/bin
    cp hello $out/bin/
  '';
}

Build it:

$ nix-build hello.nix
## Output:
## /nix/store/abc123...-hello-custom-1.0

$ ./result/bin/hello
Hello from Nix!

What happened behind the scenes:

  1. Nix hashed all inputs (source, gcc, build script)
  2. Created a derivation file (.drv)
  3. Built in an isolated sandbox at /tmp/nix-build-...
  4. Moved output to /nix/store/abc123...-hello-custom-1.0
  5. Created result symlink pointing there

Example 3: Dependency Management

Let's build a Python application with dependencies:

## myapp.nix
{ pkgs ? import <nixpkgs> {} }:

let
  pythonEnv = pkgs.python3.withPackages (ps: [
    ps.requests
    ps.flask
    ps.sqlalchemy
  ]);
in
pkgs.stdenv.mkDerivation {
  pname = "myapp";
  version = "2.1.0";
  
  src = ./app;
  
  buildInputs = [ pythonEnv pkgs.postgresql ];
  
  installPhase = ''
    mkdir -p $out/bin
    cp app.py $out/bin/myapp
    
    # Wrap with correct Python environment
    wrapProgram $out/bin/myapp \
      --prefix PATH : ${pythonEnv}/bin \
      --set PYTHONPATH ${pythonEnv}/${pythonEnv.sitePackages}
  '';
  
  nativeBuildInputs = [ pkgs.makeWrapper ];
}

The dependency graph Nix creates:

myapp β†’ pythonEnv β†’ python3 β†’ glibc β†’ gcc
            ↓           ↓
        requests    postgresql
            ↓
        urllib3
            ↓
        openssl

Every arrow is a hash-verified, immutable reference!

Example 4: Reproducible Development Environments

One of Nix's killer featuresβ€”pin exact versions:

## shell.nix
let
  # Pin nixpkgs to specific commit for reproducibility
  nixpkgs = fetchTarball {
    url = "https://github.com/NixOS/nixpkgs/archive/a7ecde854aee5c4c7cd6177f54a99d2c1ff28a31.tar.gz";
    sha256 = "162dywda2dvfj1248afxc45kcrg83appjd0nmdb541hl7rnncf02";
  };
  
  pkgs = import nixpkgs {};
in
pkgs.mkShell {
  buildInputs = [
    pkgs.nodejs-18_x
    pkgs.yarn
    pkgs.postgresql_15
    pkgs.redis
  ];
  
  shellHook = ''
    echo "Development environment ready!"
    echo "Node: $(node --version)"
    echo "PostgreSQL: $(postgres --version)"
    
    export DATABASE_URL="postgresql://localhost/myapp_dev"
  '';
}

Enter the environment:

$ nix-shell
## Development environment ready!
## Node: v18.16.0
## PostgreSQL: postgres (PostgreSQL) 15.3

$ which node
/nix/store/xyz...-nodejs-18.16.0/bin/node

$ exit

$ which node
## (node not found - back to your regular system)

🎯 Real-world benefit: Share shell.nix with your team β†’ everyone gets identical development environments, regardless of their OS or previous installations.

Common Mistakes ⚠️

Mistake 1: Treating Nix Like a Traditional Package Manager

❌ Wrong thinking: "I'll just nix-env -i everything I need"

βœ… Right thinking: "I'll declare my environment in a .nix file for reproducibility"

Why: Imperative installs with nix-env -i work, but lose many benefits:

  • No version pinning
  • No shared configuration with team
  • Hard to reproduce on another machine

Use declarative approaches instead:

## configuration.nix or home-manager
environment.systemPackages = [
  pkgs.git
  pkgs.vim
  pkgs.firefox
];

Mistake 2: Expecting Impure Builds to Work

❌ Wrong: Assuming builds can access /usr/lib or download files

## This will FAIL in Nix sandbox
buildPhase = ''
  curl https://example.com/data.json > data.json  # No network!
  gcc -I/usr/include -L/usr/lib hello.c           # No /usr!
'';

βœ… Right: Declare all inputs explicitly

buildInputs = [ pkgs.curl ];

src = fetchurl {
  url = "https://example.com/data.json";
  sha256 = "...";  # Hash required - fetched BEFORE build
};

Mistake 3: Modifying the Nix Store

❌ Wrong: Trying to edit files in /nix/store

## This will FAIL - Nix store is read-only
$ vim /nix/store/abc...-myapp/config.txt
## Error: Read-only file system

βœ… Right: Create a new derivation with the changes

## Override existing package
myModifiedApp = pkgs.myapp.overrideAttrs (old: {
  postInstall = ''
    echo "custom config" > $out/config.txt
  '';
});

Mistake 4: Not Understanding Garbage Collection

❌ Wrong: Deleting /nix/store entries manually

βœ… Right: Let Nix garbage collector manage it

## Remove old generations
nix-collect-garbage --delete-older-than 30d

## Remove ALL unused packages
nix-collect-garbage -d

Nix tracks which store paths are still referenced by:

  • Active profiles
  • Current NixOS generation
  • GC roots in /nix/var/nix/gcroots/

Mistake 5: Ignoring Binary Caches

❌ Wrong: Building everything from source

## Slow: rebuilds GCC, glibc, Python, etc.
nix-build '<nixpkgs>' -A python3

βœ… Right: Use cache.nixos.org (default) or set up your own

## nix.conf or configuration.nix
substituters = [
  "https://cache.nixos.org"
  "https://your-company-cache.example.com"
];

trusted-public-keys = [
  "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
  "your-company-cache:..."
];

Nix will download pre-built binaries when hashes match!

Key Takeaways πŸŽ“

πŸ“‹ Quick Reference: Nix Mental Model

ConceptWhat It Means
Nix StoreImmutable database of all packages at /nix/store
DerivationPure description of how to build a package
HashFingerprint of all build inputs (source + deps + scripts)
ClosurePackage + complete set of runtime dependencies
ProfileUser environment with generations for rollback
Lazy EvaluationBuilds happen on-demand, not at definition time
PurityNo network, no system paths, deterministic output
DAGDependency graph with no cycles

Core Principles to Remember:

  1. πŸ” Immutability: Nothing in /nix/store ever changes
  2. 🎯 Content-Addressed: Hashes ensure reproducibility
  3. πŸ”„ Functional: Same inputs β†’ same outputs, always
  4. πŸ—οΈ Declarative: Describe what you want, not how to build it
  5. ⚑ Composable: Mix and match packages without conflicts
  6. πŸ• Time-Travel: Rollback to any previous generation
  7. 🌍 Portable: Closures work across machines

πŸ“š Further Study

Official Resources:

πŸ’‘ Practice Tip: Start with nix-shell for development environments before diving into writing full derivations. Build your mental model incrementally!