LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Nix and NixOS for Developers: Reproducible Environments, Flakes, and devShells

nixnixosreproducibilitydeveloper-experienceflakeshome-managerdevops

Every developer has lived this story: you clone a repo, run the setup script, spend two hours debugging version conflicts, discover the README assumes Ubuntu 20.04 when you’re on 22.04, and eventually get something working that’s subtly different from what CI uses. Six months later, a new team member goes through the same ritual.

Nix solves this by making environments reproducible at the package manager level. A Nix expression specifies exact versions and dependencies — not “python >= 3.11” but a cryptographic hash identifying a specific build. Every developer who applies the same expression gets byte-for-byte identical tooling, regardless of what else is on their machine, what OS they run, or when they set it up.

This guide takes you from first principles through practical use: one-off nix shell invocations, project devShells with flakes, managing your dotfiles with home-manager, and NixOS for those who want the whole system to be reproducible.


What Makes Nix Different

The Nix Store

Everything Nix installs lives in /nix/store/. Each package gets its own directory named by the hash of all its inputs — the source code, compiler version, all dependencies, build flags:

/nix/store/
  ├── 9xgxjh8b7ank9z4vldgqn0h20x4h2j3k-python3-3.11.8/
  ├── wr3l9fhv5p2k8q7xy4c6n1m0a3d9e2b1-python3-3.12.2/
  ├── 4k9p2qz8r7m1x3c6y0w5n8b2a1d4e7f6-nodejs-20.11.0/
  ├── 7m3q9z2p8r4x1c6y0w5n8b2a1d4e7f6-git-2.44.0/
  └── ...

Because the path includes the hash, multiple versions of anything coexist without conflict. Python 3.11 and 3.12 are both installed in different paths. The compiler used to build each package is pinned by its hash. Nothing is ever mutated — new versions get new paths.

When you activate an environment, Nix symlinks the relevant binaries into your $PATH. When you leave the environment, the symlinks go away. The packages themselves stay cached in the store.

Purely Functional

Nix treats package builds like pure functions: same inputs → same outputs, always. There are no shared mutable directories (no /usr/local), no implicit dependencies on system libraries (unless explicitly declared), and no ordering effects. A Nix package that built successfully on your machine builds identically in CI and on your colleague’s laptop.

Three Layers

NixOS                  — Your entire OS declared in Nix
  └── Home Manager     — Your user environment (dotfiles, user packages)
        └── Nix Flakes — Per-project reproducible devShells
              └── Nix  — The package manager at the bottom of everything

You don’t need all four layers. Most developers start with Nix as a package manager on their existing OS, add flakes for project environments, and optionally adopt home-manager for their personal setup. NixOS is for those who want to go all the way.


Installation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Linux (single-user install — simplest)
sh <(curl -L https://nixos.org/nix/install) --no-daemon

# Linux (multi-user — recommended, required for some features)
sh <(curl -L https://nixos.org/nix/install) --daemon

# macOS (multi-user required on macOS)
sh <(curl -L https://nixos.org/nix/install)

# Verify
nix --version
# nix (Nix) 2.24.x

# Enable flakes and nix-command (the modern CLI) — add to ~/.config/nix/nix.conf:
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

Nix Shell: Temporary Environments

The fastest way to use Nix: drop into a shell with specific packages available, then leave and they’re gone from your $PATH.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Temporary shell with a specific package
nix shell nixpkgs#ripgrep
rg --version   # works
exit
rg             # command not found — clean exit

# Multiple packages
nix shell nixpkgs#nodejs_20 nixpkgs#pnpm nixpkgs#typescript
node --version   # v20.x.x
pnpm --version   # 9.x.x
tsc --version    # 5.x.x

# Run a command directly without entering a shell
nix run nixpkgs#cowsay -- "Nix is reproducible"

# Pin to a specific nixpkgs commit for true reproducibility
nix shell github:NixOS/nixpkgs/nixos-24.11#python312

# Useful for trying tools before committing to them
nix shell nixpkgs#htop nixpkgs#bottom nixpkgs#bandwhich

Flakes: The Modern Nix

Flakes are the standardized format for Nix projects. A flake.nix at the root of your repo declares inputs (pinned references to nixpkgs and other flakes) and outputs (devShells, packages, NixOS configurations, home-manager configurations).

Anatomy of a flake.nix

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
# flake.nix
{
  description = "My web application development environment";

  inputs = {
    # Pin nixpkgs to a specific release — everyone gets the same packages
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";

    # flake-utils: helpers for supporting multiple systems (x86, aarch64, Darwin)
    flake-utils.url = "github:numtide/flake-utils";

    # Rust toolchain management
    rust-overlay.url = "github:oxalica/rust-overlay";
    rust-overlay.inputs.nixpkgs.follows = "nixpkgs";  # Use our nixpkgs, not overlay's

    # devenv: higher-level devShell abstraction (optional)
    devenv.url = "github:cachix/devenv";
  };

  outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs {
          inherit system;
          overlays = [ rust-overlay.overlays.default ];
        };
      in {
        # The devShell activated by `nix develop`
        devShells.default = pkgs.mkShell {
          name = "my-app-dev";

          # Packages available in the shell
          buildInputs = with pkgs; [
            # Language runtimes
            nodejs_20
            (pkgs.rust-bin.stable.latest.default.override {
              extensions = [ "rust-src" "rust-analyzer" ];
            })
            python312

            # Build tools
            pkg-config
            openssl

            # Dev tools
            git
            gh                    # GitHub CLI
            direnv                # Auto-activate shell on cd
            just                  # Command runner
            sqlx-cli              # Database migrations
            postgresql_16         # psql client
            redis                 # redis-cli

            # Formatters and linters
            nixfmt-rfc-style      # Nix formatter
            nodePackages.prettier
            ruff                  # Python linter
          ];

          # Environment variables set when entering the shell
          env = {
            DATABASE_URL = "postgresql://localhost:5432/myapp_dev";
            REDIS_URL = "redis://localhost:6379";
            RUST_LOG = "debug";
            RUST_BACKTRACE = "1";
          };

          # Shell hook: runs when you enter the shell
          shellHook = ''
            echo "🚀 my-app dev environment"
            echo "   Node: $(node --version)"
            echo "   Rust: $(rustc --version)"
            echo "   Python: $(python3 --version)"
            echo ""

            # Create local .env if it doesn't exist
            if [ ! -f .env ]; then
              cp .env.example .env
              echo "Created .env from .env.example — fill in your secrets"
            fi

            # Ensure local postgres data dir exists
            export PGDATA="$PWD/.postgres"
            if [ ! -d "$PGDATA" ]; then
              initdb --locale C --encoding UTF8 "$PGDATA"
              echo "Initialized local PostgreSQL in .postgres/"
            fi
          '';
        };

        # You can define multiple devShells for different purposes
        devShells.ci = pkgs.mkShell {
          buildInputs = with pkgs; [
            nodejs_20
            python312
          ];
          # Minimal shell for CI — no dev tools needed
        };

        # You can also define packages your project produces
        packages.default = pkgs.buildNpmPackage {
          name = "my-app";
          src = ./.;
          npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
        };
      }
    );
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Enter the dev shell
nix develop

# Or: run a command inside the shell without entering it
nix develop --command npm install

# Show what's in the current flake
nix flake show

# Update all inputs to latest (re-pins flake.lock)
nix flake update

# Update only nixpkgs
nix flake update nixpkgs

# Check flake validity
nix flake check

The flake.lock File

When you first run nix develop, Nix creates flake.lock — a file pinning every input to an exact commit SHA:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "nodes": {
    "nixpkgs": {
      "locked": {
        "lastModified": 1709340113,
        "narHash": "sha256-oqE7PUDpLTwTPR7xBoRIKV8e7lCQiQjhEaDuVEd9+KY=",
        "owner": "NixOS",
        "repo": "nixpkgs",
        "rev": "8f7de0dc72b4e22d89d940a05b71cf3e08e11c16",
        "type": "github"
      }
    }
  }
}

Commit flake.lock to your repo. Anyone who clones it gets exactly the same packages, forever, even years later when nixpkgs has moved on.


direnv: Automatic Shell Activation

Manually running nix develop every time you cd into a project gets old fast. direnv watches for .envrc files and automatically activates/deactivates environments as you change directories.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Install direnv
nix profile install nixpkgs#direnv

# Add to your shell (bash)
echo 'eval "$(direnv hook bash)"' >> ~/.bashrc

# Or zsh
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc

# In your project, create .envrc
echo "use flake" > .envrc
direnv allow

# Now: cd into the project → shell activates automatically
# cd out → shell deactivates
~/projects/my-app$ cd .
direnv: loading ~/projects/my-app/.envrc
direnv: using flake
🚀 my-app dev environment
   Node: v20.11.0
   Rust: rustc 1.77.0
   Python: Python 3.12.2

~/projects/my-app$ cd ..
direnv: unloading

The use flake directive in .envrc evaluates the flake.nix in the current directory. For faster activation (caches the dev shell derivation), use nix-direnv:

1
2
3
4
5
# In your home-manager config or flake.nix
programs.direnv = {
  enable = true;
  nix-direnv.enable = true;  # Caches devshells, much faster re-activation
};

Per-Language Deep Dives

Node.js / JavaScript

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
devShells.default = pkgs.mkShell {
  buildInputs = with pkgs; [
    nodejs_20
    nodePackages.pnpm
    nodePackages.typescript
    nodePackages.typescript-language-server

    # Use a specific version pinned in nixpkgs
    # To find: nix search nixpkgs nodejs
  ];

  shellHook = ''
    # Use local node_modules/.bin in PATH (like npm scripts do)
    export PATH="$PWD/node_modules/.bin:$PATH"
  '';
};

For projects that need very specific npm package versions not in nixpkgs, you can generate a Nix expression from package-lock.json:

1
2
3
4
5
# Generate nix expression from package-lock.json
npx node2nix -i package.json -l package-lock.json

# Or for pnpm:
npx pnpm2nix

Python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
let
  # Create a Python environment with specific packages
  pythonEnv = pkgs.python312.withPackages (ps: with ps; [
    # Web framework
    fastapi
    uvicorn
    pydantic

    # Database
    sqlalchemy
    asyncpg
    alembic

    # Dev tools
    pytest
    pytest-asyncio
    black
    ruff
    mypy

    # The packages not in nixpkgs — install with pip inside the shell
    # or use a virtualenv
  ]);
in

devShells.default = pkgs.mkShell {
  buildInputs = [
    pythonEnv
    pkgs.postgresql_16  # For psycopg2 build deps
  ];

  shellHook = ''
    # For packages not in nixpkgs, create a venv on top
    if [ ! -d .venv ]; then
      python -m venv .venv
    fi
    source .venv/bin/activate

    # Or use uv (much faster)
    if [ ! -d .venv ]; then
      uv venv
    fi
    source .venv/bin/activate
    uv pip install -r requirements.txt
  '';
};

Rust

The rust-overlay flake gives precise control over the Rust toolchain:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    rust-overlay.url = "github:oxalica/rust-overlay";
    rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { nixpkgs, rust-overlay, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = import nixpkgs {
          inherit system;
          overlays = [ rust-overlay.overlays.default ];
        };

        # Stable Rust with specific components
        rustToolchain = pkgs.rust-bin.stable.latest.default.override {
          extensions = [
            "rust-src"          # For rust-analyzer
            "rust-analyzer"     # LSP
            "clippy"            # Linter
            "rustfmt"           # Formatter
            "llvm-tools-preview" # For cargo-llvm-cov
          ];
          targets = [
            "wasm32-unknown-unknown"  # If building for WASM
            "x86_64-unknown-linux-musl"  # Static binaries
          ];
        };

        # Or: pin to a specific nightly
        # rustToolchain = pkgs.rust-bin.nightly."2024-03-01".default;

        # Or: use rust-toolchain.toml in your project
        # rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
      in {
        devShells.default = pkgs.mkShell {
          buildInputs = [
            rustToolchain

            # Native deps commonly needed by Rust crates
            pkgs.pkg-config
            pkgs.openssl
            pkgs.openssl.dev

            # Faster linker
            pkgs.mold

            # Useful Cargo extensions
            pkgs.cargo-watch    # cargo watch -x check
            pkgs.cargo-expand   # Expand macros
            pkgs.cargo-audit    # Security audit
            pkgs.cargo-nextest  # Faster test runner
            pkgs.cargo-llvm-cov # Coverage
          ];

          env = {
            OPENSSL_DIR = "${pkgs.openssl.dev}";
            OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib";
            RUST_LOG = "debug";
            # Use mold as the linker (faster incremental builds)
            RUSTFLAGS = "-C link-arg=-fuse-ld=mold";
          };
        };
      }
    );
}

Go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
devShells.default = pkgs.mkShell {
  buildInputs = with pkgs; [
    go_1_22
    gopls               # Language server
    golangci-lint       # Linter suite
    gotools             # goimports, etc.
    delve               # Debugger
    air                 # Live reload
    sqlc                # SQL → Go type-safe queries
    buf                 # Protocol Buffers
    protoc-gen-go
    protoc-gen-go-grpc
  ];

  env = {
    GOPATH = "${toString ./.}/.gopath";
    GOFLAGS = "-mod=vendor";
  };

  shellHook = ''
    export PATH="$GOPATH/bin:$PATH"
    mkdir -p "$GOPATH"
  '';
};

devenv: A Higher-Level devShell

devenv wraps Nix flakes with a friendlier API and adds built-in support for starting services (PostgreSQL, Redis, etc.) alongside your development environment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# devenv.nix
{ pkgs, lib, config, ... }: {
  # Packages
  packages = with pkgs; [
    git
    gh
    just
  ];

  # Language support
  languages.go = {
    enable = true;
    version = "1.22";
  };

  languages.python = {
    enable = true;
    version = "3.12";
    venv.enable = true;
    venv.requirements = ./requirements.txt;
  };

  # Services that start automatically in the dev env
  services.postgres = {
    enable = true;
    listen_addresses = "127.0.0.1";
    port = 5432;
    initialScript = ''
      CREATE DATABASE myapp_dev;
      CREATE USER myapp WITH PASSWORD 'devpassword';
      GRANT ALL PRIVILEGES ON DATABASE myapp_dev TO myapp;
    '';
  };

  services.redis = {
    enable = true;
    port = 6379;
  };

  # Custom scripts available in the shell
  scripts.db-reset.exec = ''
    psql myapp_dev -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
    alembic upgrade head
    echo "Database reset complete"
  '';

  scripts.test.exec = ''
    pytest -xvs "$@"
  '';

  # Environment variables
  env.DATABASE_URL = "postgresql://myapp:devpassword@localhost:5432/myapp_dev";
  env.REDIS_URL = "redis://localhost:6379/0";

  # Pre-commit hooks (runs on git commit)
  pre-commit.hooks = {
    ruff.enable = true;
    black.enable = true;
    mypy.enable = true;
  };

  # Shell init message
  enterShell = ''
    echo "Services: postgres=$(pg_isready && echo up || echo down) redis=$(redis-cli ping)"
  '';
}
1
2
3
4
5
6
7
# With devenv installed:
devenv shell    # Enter the shell (starts services in background)
devenv up       # Start services in the foreground (shows logs)
devenv test     # Run the checks defined in devenv.nix

# Services use process-compose under the hood
# Logs go to .devenv/processes.log

Home Manager: Declarative User Environments

Home Manager applies the Nix model to your entire user environment: packages, dotfiles, shell configuration, editor setup — all declared in Nix, reproducible across machines.

Standalone Setup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Add home-manager channel
nix-channel --add https://github.com/nix-community/home-manager/archive/release-24.11.tar.gz home-manager
nix-channel --update

# Install
nix-shell '<home-manager>' -A install

# Generate initial config
home-manager init
# Creates: ~/.config/home-manager/home.nix

A Practical home.nix

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# ~/.config/home-manager/home.nix
{ config, pkgs, ... }: {

  home.username = "jdoe";
  home.homeDirectory = "/home/jdoe";
  home.stateVersion = "24.11";

  # Enable home-manager to manage itself
  programs.home-manager.enable = true;

  # ─── Packages ────────────────────────────────────────────────────────────
  home.packages = with pkgs; [
    # CLI essentials
    ripgrep
    fd
    bat           # cat with syntax highlighting
    eza           # modern ls
    fzf           # fuzzy finder
    zoxide        # smart cd
    delta         # better git diff
    dust          # du alternative
    duf           # df alternative
    bottom        # system monitor
    httpie        # friendly curl
    jq
    yq
    mkcert

    # Dev tools
    gh
    git-lfs
    pre-commit
    just
    direnv
    nix-direnv

    # K8s tools
    kubectl
    kubectx
    k9s
    helm
    kustomize
    stern         # multi-pod log tailing

    # Cloud CLIs
    awscli2
    google-cloud-sdk

    # Fonts
    (nerdfonts.override { fonts = [ "JetBrainsMono" "FiraCode" ]; })
  ];

  # ─── Git ─────────────────────────────────────────────────────────────────
  programs.git = {
    enable = true;
    userName = "Jane Doe";
    userEmail = "jane@example.com";

    signing = {
      key = "~/.ssh/id_ed25519.pub";
      signByDefault = true;
      format = "ssh";
    };

    delta = {
      enable = true;
      options = {
        navigate = true;
        side-by-side = true;
        line-numbers = true;
        syntax-theme = "Catppuccin-mocha";
      };
    };

    extraConfig = {
      init.defaultBranch = "main";
      pull.rebase = true;
      push.autoSetupRemote = true;
      rerere.enabled = true;
      branch.sort = "-committerdate";
      core.editor = "nvim";
    };

    aliases = {
      st = "status -sb";
      lg = "log --oneline --graph --decorate --all";
      undo = "reset --soft HEAD~1";
      wip = "commit -am 'WIP'";
      fixup = "commit --fixup";
    };
  };

  # ─── Shell: Zsh ──────────────────────────────────────────────────────────
  programs.zsh = {
    enable = true;
    enableCompletion = true;
    autosuggestion.enable = true;
    syntaxHighlighting.enable = true;

    history = {
      size = 100000;
      save = 100000;
      share = true;
      ignoreDups = true;
      ignoreSpace = true;
    };

    shellAliases = {
      ls = "eza --icons";
      ll = "eza -la --icons --git";
      lt = "eza --tree --icons --level=2";
      cat = "bat";
      grep = "rg";
      top = "btm";
      k = "kubectl";
      kx = "kubectx";
      kn = "kubens";
      tf = "terraform";
      hm = "home-manager";
    };

    initExtra = ''
      # zoxide (smart cd)
      eval "$(zoxide init zsh)"

      # fzf key bindings
      source ${pkgs.fzf}/share/fzf/key-bindings.zsh
      source ${pkgs.fzf}/share/fzf/completion.zsh

      # direnv
      eval "$(direnv hook zsh)"

      # Use fd as fzf's find command
      export FZF_DEFAULT_COMMAND="fd --type f --hidden --follow --exclude .git"
      export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"

      # Better history search with fzf
      export FZF_CTRL_R_OPTS="--sort --exact"
    '';
  };

  programs.starship = {
    enable = true;
    settings = {
      format = "$directory$git_branch$git_status$nix_shell$cmd_duration$line_break$character";
      nix_shell = {
        format = "[$symbol$name]($style) ";
        symbol = "❄️ ";
      };
      git_status = {
        ahead = "⇡$count";
        behind = "⇣$count";
        diverged = "⇕⇡$ahead_count⇣$behind_count";
        modified = "!$count";
        untracked = "?$count";
        staged = "+$count";
      };
    };
  };

  # ─── Neovim ──────────────────────────────────────────────────────────────
  programs.neovim = {
    enable = true;
    defaultEditor = true;
    viAlias = true;
    vimAlias = true;

    plugins = with pkgs.vimPlugins; [
      lazy-nvim
      nvim-treesitter.withAllGrammars
      telescope-nvim
      nvim-lspconfig
      nvim-cmp
      cmp-nvim-lsp
      luasnip
      catppuccin-nvim
      lualine-nvim
      gitsigns-nvim
      neo-tree-nvim
    ];

    extraConfig = ''
      lua require('config.init')
    '';
  };

  # ─── SSH ─────────────────────────────────────────────────────────────────
  programs.ssh = {
    enable = true;
    addKeysToAgent = "yes";

    matchBlocks = {
      "github.com" = {
        identityFile = "~/.ssh/id_ed25519";
        identitiesOnly = true;
      };
      "bastion.prod.*" = {
        user = "ubuntu";
        identityFile = "~/.ssh/id_ed25519";
        forwardAgent = false;
      };
    };
  };

  # ─── Direnv ──────────────────────────────────────────────────────────────
  programs.direnv = {
    enable = true;
    nix-direnv.enable = true;
  };

  # ─── File Templates ──────────────────────────────────────────────────────
  # home-manager can manage arbitrary dotfiles
  home.file.".config/k9s/skins/skin.yml".source = ./config/k9s-skin.yml;
  home.file.".config/bat/config".text = ''
    --theme="Catppuccin-mocha"
    --style=numbers,changes,header
    --italic-text=always
  '';
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Apply the configuration
home-manager switch

# Roll back to the previous generation
home-manager generations
home-manager rollback

# Update home-manager and all inputs
nix-channel --update
home-manager switch --update-input home-manager

Home Manager as a Flake

The modern approach: manage home-manager through a flake for pinned reproducibility:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# flake.nix — home configuration as a flake
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    home-manager = {
      url = "github:nix-community/home-manager/release-24.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { nixpkgs, home-manager, ... }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      homeConfigurations."jdoe@workstation" = home-manager.lib.homeManagerConfiguration {
        inherit pkgs;
        modules = [ ./home.nix ];
      };

      # Support multiple machines with shared config
      homeConfigurations."jdoe@laptop" = home-manager.lib.homeManagerConfiguration {
        pkgs = nixpkgs.legacyPackages."aarch64-darwin";  # M-series Mac
        modules = [
          ./home.nix
          ./home-darwin.nix  # macOS-specific overrides
        ];
      };
    };
}
1
2
3
4
5
# Apply (using the flake)
home-manager switch --flake .#jdoe@workstation

# On a new machine: set up your entire environment with one command
nix run home-manager/master -- switch --flake github:jdoe/dotfiles#jdoe@workstation

NixOS: The Whole System

On NixOS, the entire operating system — kernel, init system, services, users, network configuration, bootloader — is declared in Nix. The nixos-rebuild switch command atomically applies changes, and every previous configuration is a bootable entry in the GRUB menu.

A Minimal NixOS Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# /etc/nixos/configuration.nix
{ config, pkgs, ... }: {
  imports = [
    ./hardware-configuration.nix  # Auto-generated from hardware scan
    ./modules/desktop.nix
    ./modules/development.nix
  ];

  # ─── Boot ───────────────────────────────────────────────────────────────
  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  # ─── Networking ─────────────────────────────────────────────────────────
  networking.hostName = "nixstation";
  networking.networkmanager.enable = true;

  # Firewall
  networking.firewall = {
    enable = true;
    allowedTCPPorts = [ 22 80 443 ];
  };

  # ─── Time and Locale ─────────────────────────────────────────────────────
  time.timeZone = "America/New_York";
  i18n.defaultLocale = "en_US.UTF-8";

  # ─── Users ──────────────────────────────────────────────────────────────
  users.users.jdoe = {
    isNormalUser = true;
    extraGroups = [ "wheel" "docker" "networkmanager" "video" "audio" ];
    shell = pkgs.zsh;
    openssh.authorizedKeys.keys = [
      "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... jdoe@laptop"
    ];
  };

  # Passwordless sudo for wheel group
  security.sudo.wheelNeedsPassword = false;

  # ─── Services ───────────────────────────────────────────────────────────
  services.openssh = {
    enable = true;
    settings = {
      PasswordAuthentication = false;
      PermitRootLogin = "no";
      X11Forwarding = false;
    };
  };

  services.tailscale.enable = true;

  virtualisation.docker = {
    enable = true;
    enableOnBoot = false;  # Start on first use
  };

  # ─── System Packages ────────────────────────────────────────────────────
  # These are available system-wide (prefer home-manager for user tools)
  environment.systemPackages = with pkgs; [
    vim
    wget
    curl
    git
    htop
  ];

  programs.zsh.enable = true;

  # ─── Nix Settings ───────────────────────────────────────────────────────
  nix = {
    settings = {
      experimental-features = [ "nix-command" "flakes" ];
      auto-optimise-store = true;
      trusted-users = [ "@wheel" ];

      # Binary caches — pre-built binaries so you don't build everything
      substituters = [
        "https://cache.nixos.org"
        "https://nix-community.cachix.org"
        "https://devenv.cachix.org"
      ];
      trusted-public-keys = [
        "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
        "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCUSeBo="
        "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
      ];
    };

    # Automatic garbage collection
    gc = {
      automatic = true;
      dates = "weekly";
      options = "--delete-older-than 30d";
    };
  };

  system.stateVersion = "24.11";
}

NixOS as a Flake

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# flake.nix — full system + home-manager configuration
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
    home-manager = {
      url = "github:nix-community/home-manager/release-24.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    # Hardware-specific optimizations
    nixos-hardware.url = "github:NixOS/nixos-hardware";
  };

  outputs = { nixpkgs, home-manager, nixos-hardware, ... }: {
    nixosConfigurations = {
      # Desktop workstation
      nixstation = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          nixos-hardware.nixosModules.common-cpu-amd
          nixos-hardware.nixosModules.common-gpu-amd
          ./hosts/nixstation/configuration.nix
          home-manager.nixosModules.home-manager {
            home-manager.useGlobalPkgs = true;
            home-manager.useUserPackages = true;
            home-manager.users.jdoe = import ./home.nix;
          }
        ];
      };

      # Home server
      homeserver = nixpkgs.lib.nixosSystem {
        system = "x86_64-linux";
        modules = [
          ./hosts/homeserver/configuration.nix
        ];
      };
    };
  };
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Apply a NixOS configuration
sudo nixos-rebuild switch --flake .#nixstation

# Boot into the new config without switching (test first)
sudo nixos-rebuild boot --flake .#nixstation

# Roll back to the last generation
sudo nixos-rebuild switch --rollback

# List all generations (every nixos-rebuild creates one)
nix-env --list-generations --profile /nix/var/nix/profiles/system

# Build without activating (to check for errors)
nixos-rebuild build --flake .#nixstation

Binary Caches

Building everything from source is slow. Binary caches (substituters) serve pre-built derivations so Nix downloads binaries instead of compiling them. cache.nixos.org covers the entire nixpkgs package set.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Check if a package has a binary in the public cache
nix path-info --store https://cache.nixos.org nixpkgs#nodejs_20

# Set up a private cache with Cachix (for your own packages)
nix-env -iA cachix -f https://cachix.org/api/v1/install
cachix authtoken <your-token>
cachix use my-org-cache

# Push your devShell's closure to the cache
nix develop --profile /tmp/dev-profile
cachix push my-org-cache /tmp/dev-profile

Common Patterns and Pitfalls

Searching for Packages

1
2
3
4
5
6
7
8
# Search nixpkgs
nix search nixpkgs python312
nix search nixpkgs#python312Packages tensorflow

# Or use the web: search.nixos.org

# Find which package provides a binary
nix-locate bin/rg  # requires nix-index

When a Package Isn’t in nixpkgs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Option 1: Override an existing package's source
pkgs.somePackage.overrideAttrs (old: {
  version = "1.2.3";
  src = pkgs.fetchFromGitHub {
    owner = "example";
    repo = "somepackage";
    rev = "v1.2.3";
    sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
  };
});

# Option 2: Write a simple derivation
pkgs.stdenv.mkDerivation {
  pname = "my-tool";
  version = "1.0.0";
  src = pkgs.fetchurl {
    url = "https://example.com/my-tool-1.0.0.tar.gz";
    sha256 = "sha256-AAAAAAA...";
  };
  buildInputs = [ pkgs.openssl ];
  installPhase = ''
    mkdir -p $out/bin
    cp my-tool $out/bin/
  '';
}

# Option 3: Use pkgs.writeShellScriptBin for simple scripts
pkgs.writeShellScriptBin "my-script" ''
  #!/bin/bash
  echo "Hello from Nix"
''

Imperative Package Management (nix profile)

For one-off global installs without a flake:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Install a package globally (user profile)
nix profile install nixpkgs#ripgrep

# List installed packages
nix profile list

# Remove a package
nix profile remove ripgrep

# Update all packages
nix profile upgrade '.*'

Garbage Collection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Remove unused packages (generations keep old packages alive)
nix-collect-garbage

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

# See what's taking space
nix path-info --all --size | sort -k2 -rn | head -20

# Optimize the store (hard-link identical files)
nix store optimise

The Nix Learning Curve

Nix has a genuinely steep learning curve. The language is unusual (lazy, purely functional, dynamically typed), the error messages are cryptic, and the documentation is scattered. Here’s a realistic learning path:

Week 1: nix shell and nix run
  → Try tools without installing them
  → Get comfortable with the nix CLI

Week 2: Your first flake.nix
  → Add a flake to a project you own
  → Set up direnv for automatic activation
  → Commit flake.lock to the repo

Month 2: Home Manager
  → Move your dotfiles into home.nix
  → Manage git, shell, and editor config declaratively
  → Experience your first "switch everything to a new machine in 30 minutes"

Month 3+: NixOS (optional)
  → Full system declaration
  → Atomic upgrades and rollbacks
  → The "it works the same everywhere" payoff

The investment is real, but the payoff is too: a development environment that any new team member can reproduce in minutes, CI that uses exactly the same tooling as local development, and a system you can rebuild from scratch after a hardware failure without remembering what you installed.


Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Explore
nix search nixpkgs <query>           # Search packages
nix shell nixpkgs#<pkg>              # Temp shell with package
nix run nixpkgs#<pkg> -- <args>      # Run a program

# Flakes
nix flake init                       # Create a new flake
nix develop                          # Enter devShell
nix develop --command <cmd>          # Run command in devShell
nix flake update                     # Update all inputs
nix flake show                       # Show flake outputs
nix flake check                      # Validate the flake

# Home Manager
home-manager switch                  # Apply home.nix
home-manager generations             # List generations
home-manager rollback                # Roll back one generation

# NixOS
nixos-rebuild switch                 # Apply and activate
nixos-rebuild switch --rollback      # Roll back
nixos-rebuild build                  # Build without activating

# Maintenance
nix-collect-garbage -d               # Delete old generations + GC
nix store optimise                   # Deduplicate store
nix store verify --all               # Verify store integrity

Filed under: Developer Tooling & Workflow

Comments