Development environments

Different projects need different tools at different versions. A Node project pinned to version 18 and another pinned to 22 cannot both be satisfied by one globally installed binary. A Python project that needs Flask 3 and an older one that needs Flask 2 will fight over the same site-packages directory. The traditional answer is version managers layered on top of global installs, which every contributor has to configure the same way on their own machine, and which still produce subtle differences between setups.

Nix offers a cleaner answer. A development shell is a closed environment that puts exactly the packages you declare on PATH and removes them when you leave. Anyone who clones the repo runs one command and gets the same tools at the same versions, pinned by the project's flake.lock just like the rest of the build. No global installs, no version manager, no divergence between machines.

A devShells flake

A dev shell is a devShells output in flake.nix. The layout below is the minimal working shape, ready to extend with whatever your project needs.

{
  description = "Project dev shell";
 
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
 
  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
    in
    {
      devShells.${system}.default = pkgs.mkShell {
        packages = [
          pkgs.git
          pkgs.curl
          pkgs.jq
        ];
 
        shellHook = ''
          echo "dev shell ready"
        '';
      };
    };
}

devShells.x86_64-linux.default is the output nix develop finds when you do not name a specific shell. packages is a list of derivations that land on PATH inside the environment. shellHook is a bash string Nix executes when the shell starts, which is where you print versions, export environment variables, or run a quick project sanity check.

Enter the shell from the directory that holds flake.nix.

nix develop

Type exit or press Ctrl-D to leave and return to your system environment. The packages are gone the moment you exit.

Real stacks

The sections below show complete mkShell bodies for three common stacks. Swap the packages into the flake above and the pattern is identical for any other stack.

Node with pnpm

devShells.${system}.default = pkgs.mkShell {
  packages = [
    pkgs.nodejs_22
    pkgs.pnpm
    pkgs.typescript
  ];
 
  shellHook = ''
    echo "node $(node --version)"
    echo "pnpm $(pnpm --version)"
  '';
};

Node follows the naming pattern nodejs_22, nodejs_20, nodejs_18, and so on in nixpkgs, so each project pins a specific major version in its own flake. Two projects that need different Node versions can coexist on the same machine without a version manager because neither one touches the global environment.

Python

python3.withPackages builds an interpreter that already has the listed packages on its import path. The result is a single derivation you pass into packages.

devShells.${system}.default = pkgs.mkShell {
  packages = [
    (pkgs.python3.withPackages (ps: with ps; [
      flask
      requests
      pytest
      black
    ]))
    pkgs.ruff
  ];
 
  shellHook = ''
    echo "python $(python --version)"
  '';
};

ps inside the lambda is the Python package set, equivalent to pkgs.python3Packages. Tools that are not Python packages but belong in the project environment go alongside as regular derivations, as ruff shows here.

Rust

The simplest Rust shell pulls the toolchain components from nixpkgs directly.

devShells.${system}.default = pkgs.mkShell {
  packages = [
    pkgs.rustc
    pkgs.cargo
    pkgs.rust-analyzer
    pkgs.clippy
    pkgs.rustfmt
    pkgs.pkg-config
    pkgs.openssl
  ];
 
  shellHook = ''
    echo "rustc $(rustc --version)"
  '';
};

For a nightly toolchain or a version that is not current stable, the community flakes fenix and rust-overlay are the standard approaches. Declare one as a flake input and follow its documentation to replace the toolchain packages above.

Environment variables in shellHook

shellHook is also where you export variables the project expects at runtime. Variables set there are only present inside the dev shell and do not leak into the rest of the system.

shellHook = ''
  export DATABASE_URL="postgres://localhost:5432/mydb"
  export LOG_LEVEL="debug"
  export NODE_ENV="development"
'';

For values that must not appear in source control, keep them out of flake.nix and load them through direnv (covered below) from an .envrc file you add to .gitignore. A bare .envrc does nothing on its own. It is read only once direnv is installed and you have run direnv allow, and nix develop does not read it.

Automatic activation with direnv

Typing nix develop by hand each time is easy to forget, especially when switching between projects. direnv solves this by watching directories and loading an .envrc file the moment you cd into one. nix-direnv is the fast Nix driver for direnv that caches dev shells so they appear instantly on subsequent visits instead of rebuilding from scratch.

Installing through Home Manager

The cleanest way to set up the pair on a Ternix-managed machine is a two-line addition to your Home Manager config.

programs.direnv = {
  enable = true;
  nix-direnv.enable = true;
};

Home Manager writes the direnv hook into your shell's startup file automatically, so no manual sourcing is needed. Run a switch to apply it.

home-manager switch --flake .#user

Installing system-wide

If you prefer a system-wide install, add both packages to your NixOS configuration.

environment.systemPackages = [
  pkgs.direnv
  pkgs.nix-direnv
];

Then add the hook to your shell's rc file.

# ~/.bashrc
eval "$(direnv hook bash)"

Replace bash with zsh or fish to match your shell. If you already use the Home Manager programs.direnv option, skip this step.

Setting up a project

Inside the project directory, write a one-line .envrc and then tell direnv to trust it.

echo "use flake" > .envrc
direnv allow

From that point forward, entering the directory loads the dev shell and leaving unloads it. Your shell prompt updates to show which environment is active. When you edit flake.nix, direnv detects the change and reloads the shell on the next directory visit. To re-evaluate without leaving and re-entering, run direnv reload.

Ad hoc shells without a flake

For a quick experiment that does not belong in any project, nix shell opens a temporary environment with one or more packages on PATH.

# a shell with jq and ripgrep available
nix shell nixpkgs#jq nixpkgs#ripgrep
 
# combine several packages in one shell
nix shell nixpkgs#ffmpeg nixpkgs#imagemagick nixpkgs#exiftool

The packages disappear when you exit. Nothing is installed globally and nothing touches your system profile.

nix run goes one step further and executes a package directly without opening an interactive shell at all.

# run cowsay without installing it
nix run nixpkgs#cowsay -- "hello from Nix"
 
# format a Nix file with alejandra
nix run nixpkgs#alejandra -- ./flake.nix

Everything after -- is forwarded as arguments to the program. The nixpkgs# prefix resolves through the flake registry (the system or user nixpkgs entry), not through your project's flake.lock, so the version is independent of any project pin. To run a package at a project's locked revision, reference that flake directly, for example nix run .#mypackage.

Advanced

Multiple named shells

A single flake can expose several shells under different names. This is useful when CI needs a leaner environment without development-only tools, or when different contributors need different toolsets from the same repository.

outputs = { self, nixpkgs }:
  let
    system = "x86_64-linux";
    pkgs = import nixpkgs { inherit system; };
  in
  {
    devShells.${system}.default = pkgs.mkShell {
      packages = [ pkgs.nodejs_22 pkgs.pnpm pkgs.typescript ];
    };
 
    devShells.${system}.ci = pkgs.mkShell {
      packages = [ pkgs.nodejs_22 pkgs.pnpm ];
    };
 
    devShells.${system}.docs = pkgs.mkShell {
      packages = [ pkgs.mdbook pkgs.pandoc ];
    };
  };

Enter a non-default shell by naming it after the #.

nix develop .#ci
nix develop .#docs

In a project .envrc, pass the shell name to use flake as well.

use flake .#ci

Pinning a different nixpkgs for one shell

When a project needs a tool at a version only available in a different nixpkgs branch, declare a second input and import it into its own package set. Both revisions are tracked in flake.lock and the build stays reproducible.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    nixpkgs-stable.url = "github:NixOS/nixpkgs/nixos-24.11";
  };
 
  outputs = { self, nixpkgs, nixpkgs-stable }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
      pkgs-stable = import nixpkgs-stable { inherit system; };
    in
    {
      devShells.${system}.default = pkgs.mkShell {
        packages = [
          pkgs.nodejs_22
          pkgs-stable.python3
        ];
      };
    };
}

pkgs and pkgs-stable are two independent package sets imported from two different nixpkgs revisions. You can mix packages from both in a single mkShell, and nix flake update nixpkgs-stable moves that input independently of the main one.

Next

  • Flakes explains inputs, the lock file, and how pinning works across a full system config.
  • Home Manager covers setting up direnv and managing the rest of your user environment as code.
  • Examples has copy-ready configurations including a complete dev shell alongside NixOS and Home Manager setups.
  • Commands and scripts collects the Nix commands you reach for day to day.