Nix Shells and the devShell Concept

Updated

September 12, 2026

Nix Shells and the devShell Concept

Compilers on the host (apt install golang, brew install go) rot the next repo. The boring default is: pkgs.mkShell in the repo, entered with nix develop (flake) or nix-shell (classic), gone when you exit.

Mental model

A devShell is a child environment, not an install:

  1. Nix realises the listed packages into /nix/store.
  2. It prepends their bin/ to PATH (and sets PKG_CONFIG_PATH, etc.).
  3. It runs shellHook.
  4. exit drops every injection. The host /usr is unchanged.
host PATH = /usr/bin
    │
    ├── nix develop / nix-shell
    │     PATH = /nix/store/…-go/bin:/nix/store/…-jq/bin:…
    │     shellHook
    └── exit → host PATH again
File Command
shell.nix nix-shell (often still <nixpkgs>)
flake.nix devShells nix develop + flake.lockdesk default

packages on mkShell is the modern list of tools. buildInputs / nativeBuildInputs still work and matter when you compile C in the shell.

Worked examples

Case 1: Classic shell.nix

Save as shell.nix:

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

pkgs.mkShell {
  packages = [
    pkgs.ripgrep
    pkgs.jq
  ];
  shellHook = ''
    echo "=== Desk Shell Ready ==="
  '';
}
nix-shell --run 'which jq && jq --version'

Output (shape):

=== Desk Shell Ready ===
/nix/store/…-jq-…/bin/jq
jq-1.7.1

Outside the shell, which jq may be missing or a different binary.

Case 2: Declared environment

Save as env_shell.nix:

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

pkgs.mkShell {
  packages = [ pkgs.curl ];
  env = {
    DESK_API_URL = "https://api.desk.internal";
    DESK_TIMEOUT = "30";
  };
  shellHook = ''
    echo "Targeting API: $DESK_API_URL"
  '';
}
nix-shell env_shell.nix --run 'echo Timeout: $DESK_TIMEOUT'

Output:

Targeting API: https://api.desk.internal
Timeout: 30

Prefer env. over export in shellHook so the variables are structured. Do not put secrets here.

Case 3: One-shot without a file

nix shell github:NixOS/nixpkgs/nixos-26.05#jq --command jq --version

Useful for five minutes. A repo still needs a committed shell so CI and humans match.

Case 4: Flake devShells.default

# flake.nix
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
  outputs = { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in
    {
      devShells.x86_64-linux.default = pkgs.mkShell {
        packages = [ pkgs.go pkgs.jq ];
      };
    };
}
nix develop --command go version

Same packages every laptop with the same flake.lock. Next chapter.

Case 5: packages vs nativeBuildInputs

pkgs.mkShell {
  packages = [ pkgs.go ];                 # on PATH
  nativeBuildInputs = [ pkgs.pkg-config ]; # also on PATH; marked build-time
  buildInputs = [ pkgs.openssl ];          # headers + libs for compiles
}

For a Go API with CGO off, packages = [ pkgs.go pkgs.gopls ] is enough. When you compile openssl-using C, you need buildInputs.

The trap

The trap is apt install golang “so I don’t need Nix for a minute.” The minute becomes the project. Two versions of go on PATH; CI uses Nix; you do not.

The other trap is secrets in shellHook (export AWS_SECRET=…). The file is git. Use sops / direnv dotenv that is gitignored.

The boring rule

  • One mkShell per repo. Flake + lock when you can.
  • packages for CLIs. buildInputs for libraries you link.
  • env. for non-secret variables. shellHook for echo and alias.
  • exit must restore the host. If it does not, you exported into the parent (you used nix-shell wrong, or direnv — part 3 later).
  • No compilers via apt/brew for desk work.

Try this

  1. Add pkgs.hello to Case 1; nix-shell --run hello.
  2. which jq inside vs after exit.
  3. nix-shell --pure --run 'echo $PATH' and notice /usr/bin is gone or last — --pure is how you catch host leaks.
  4. Put DESK_TOKEN in env then delete it; use a gitignored .env instead if you needed it.