Keystone SystemsKS Systems

Nix Dev Shell Concepts

Global development dependencies lead to conflicts and "works on my machine" problems. Nix dev shells provide isolated, reproducible environments per project.

The Problem with Global Dependencies

Consider a typical development machine:

  • Project A needs Node 18
  • Project B needs Node 20
  • Project C needs Node 16 for legacy reasons

Solutions like nvm help, but:

  • You forget to switch versions
  • You need multiple version managers (nvm, pyenv, rbenv...)
  • New team members need a long setup document
  • CI might use different versions

What is a Dev Shell?

A Nix dev shell is a temporary environment containing exactly the tools a project needs. Enter the project directory, get the right tools. Leave, they're gone from your PATH.

# flake.nix
{
  inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";

  outputs = { self, nixpkgs }:
    let
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
    in {
      devShells.x86_64-linux.default = pkgs.mkShell {
        buildInputs = [
          pkgs.nodejs_20
          pkgs.pnpm
          pkgs.postgresql_16
        ];

        shellHook = ''
          echo "Node $(node --version)"
          echo "Dev environment ready"
        '';
      };
    };
}

Enter with:

nix develop

How It Works

When you run nix develop:

  1. Nix reads the flake and builds the shell environment
  2. A new shell spawns with modified environment variables
  3. PATH includes only the specified packages (plus system essentials)
  4. Any shellHook commands run
  5. When you exit, your original environment returns

Packages aren't "installed" globally. They exist in /nix/store and are temporarily available.

Key Patterns

mkShell

The standard way to define development environments:

pkgs.mkShell {
  # Packages available in the shell
  buildInputs = [ pkgs.git pkgs.vim ];

  # Native dependencies for building
  nativeBuildInputs = [ pkgs.pkg-config ];

  # Environment variables
  DATABASE_URL = "postgresql://localhost/dev";

  # Commands to run on shell entry
  shellHook = ''
    export CUSTOM_VAR="value"
  '';
}

Multiple Shells

Define shells for different purposes:

devShells = {
  default = pkgs.mkShell { ... };
  ci = pkgs.mkShell { ... };  # Minimal for CI
  full = pkgs.mkShell { ... };  # Everything for local dev
};

Enter with nix develop .#ci or nix develop .#full.

Language-Specific Patterns

For interpreted languages, include the runtime and package manager:

# Python
buildInputs = [
  pkgs.python311
  pkgs.python311Packages.pip
  pkgs.python311Packages.virtualenv
];

# Node
buildInputs = [
  pkgs.nodejs_20
  pkgs.pnpm
];

# Rust
buildInputs = [
  pkgs.rustc
  pkgs.cargo
  pkgs.rust-analyzer
];

Integration with direnv

Typing nix develop every time is tedious. direnv automates this:

# .envrc
use flake

Now, entering the directory automatically activates the dev shell. Your IDE picks up the environment too.

Setup:

# Install direnv (via Home Manager or system)
# Add to your shell config:
eval "$(direnv hook zsh)"  # or bash

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

Benefits for Teams

Onboarding

Old way:

  1. Read the 47-step setup document
  2. Install 12 different tools
  3. Debug why versions don't match
  4. Give up and ask a colleague

New way:

git clone <repo>
cd <repo>
direnv allow
# Done

Consistency

Every developer has identical tool versions. CI uses the same environment. "Works on my machine" becomes "works on every machine."

Self-Documenting

The flake.nix is the documentation of project requirements. No separate setup guide to maintain.

Beyond Development

Dev shells can include services:

shellHook = ''
  # Start Postgres in background
  pg_ctl -D .postgres -l .postgres/log start
'';

Or use more sophisticated tools like devenv or process-compose for multi-service development environments.