Automatic Dev Shells with direnv

Updated

September 12, 2026

Automatic Dev Shells with direnv

Running nix develop by hand is ceremony you will forget. direnv eliminates it: the moment you cd into a project directory, your shell gains every tool that project declared. nix-direnv makes that transition instant and GC-safe. Together they are the boring default for any infrastructure desk running multiple projects side by side.

Mental model

direnv is a shell hook. After you add it to your shell’s init file, it intercepts every directory change. When the new directory contains a file named .envrc, direnv evaluates that file and exports the resulting environment variables into your running shell. When you leave the directory, those variables vanish.

nix-direnv provides a single function — use flake — that you call from .envrc. It replaces direnv’s slower built-in use nix with a version that:

  1. Builds the devShell once and caches the result in /nix/store.
  2. Creates a GC-root symlink at .direnv/flake-profile so that nix-collect-garbage -d cannot evict the store paths the shell depends on.
  3. Re-evaluates only when flake.nix, flake.lock, or .envrc changes — not on every cd.

Lifecycle

cd project/
  └─ direnv detects .envrc
       └─ nix-direnv: cache hit? → restore env (ms)
                       cache miss? → nix build → cache → restore env (s/min, once)
            └─ PATH, PKG_CONFIG_PATH, … exported into current shell
cd ..
  └─ direnv unloads the environment

The only files a project needs beyond flake.nix are:

File Purpose Commit?
.envrc tells direnv to use flake yes
.direnv/ nix-direnv cache and GC root no (gitignore)

Worked examples

Case 1: Installing direnv and nix-direnv via Home Manager

Home Manager is the standard way to install direnv on a NixOS workstation. The module handles shell hook injection automatically — you do not touch .bashrc or .zshrc by hand.

Save as home.nix:

# home.nix
{ pkgs, ... }:

{
  programs.direnv = {
    enable = true;            # installs direnv and adds the shell hook
    nix-direnv.enable = true; # replaces built-in use_nix with nix-direnv
  };

  # Optional: silence direnv on every cd; it still prints on changes.
  home.sessionVariables = {
    DIRENV_LOG_FORMAT = "";
  };
}

Apply the configuration:

home-manager switch

Output:

Starting Home Manager activation
Activating checkFilesChanged
Activating installPackages
replacing /home/ops/.nix-profile → /nix/store/…-home-manager-path
Activating linkGeneration
Activating onFilesChange
Activating reloadSystemd

Open a new shell (or exec $SHELL) so the hook takes effect. Now trust the first project directory:

direnv allow ~/projects/desk

Output:

direnv: loading ~/projects/desk/.envrc
direnv: using flake
direnv: nix-direnv: renewed cache
direnv: export +AR +AS +CC +CONFIG_SHELL … +buildInputs

direnv allow is a one-time command per directory. It records a hash of .envrc in ~/.local/share/direnv/allow/. If .envrc changes later you must run direnv allow again — a deliberate safety gate.

Case 2: A Go desk project

The infrastructure desk maintains a small Go service that talks to the internal metrics API. The flake declares a devShell with go, gopls, and gotools. The .envrc is a single line.

Save as flake.nix:

# flake.nix
{
  description = "Desk metrics service";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        devShells.default = pkgs.mkShell {
          name = "desk-metrics";
          packages = [
            pkgs.go_1_23
            pkgs.gopls
            pkgs.gotools  # goimports, etc.
          ];

          shellHook = ''
            echo "desk-metrics shell: $(go version)"
          '';
        };
      });
}

Save as .envrc:

# .envrc
use flake
watch_file flake.lock
# optional: local secrets not in git
# dotenv_if_exists .envrc.local

Allow and enter:

cd ~/projects/desk-metrics
direnv allow

Output:

direnv: loading ~/projects/desk-metrics/.envrc
direnv: using flake
direnv: nix-direnv: building flake output 'devShell.x86_64-linux.default'
[… nix build output …]
direnv: nix-direnv: renewed cache
desk-metrics shell: go version go1.23.4 linux/amd64
direnv: export +AR +AS +CC +CGO_ENABLED +GOPATH … +buildInputs

Subsequent entries are instant:

cd ..
cd ~/projects/desk-metrics

Output:

direnv: loading ~/projects/desk-metrics/.envrc
direnv: using flake
direnv: nix-direnv: using cached dev shell
desk-metrics shell: go version go1.23.4 linux/amd64
direnv: export +AR +AS +CC +CGO_ENABLED +GOPATH … +buildInputs

Verify the toolchain:

which go gopls goimports

Output:

/nix/store/…-go-1.23.4/bin/go
/nix/store/…-gopls-0.16.2/bin/gopls
/nix/store/…-gotools-2024-01-01/bin/goimports

Case 3: A polyglot project with named devShells

The desk’s internal portal has a Go backend and a Node/TypeScript frontend. They live in the same repository but need separate toolchains. A single flake with two named devShells keeps everything in one place.

Save as flake.nix:

# flake.nix
{
  description = "Desk portal — backend + frontend";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        devShells.backend = pkgs.mkShell {
          name = "portal-backend";
          packages = [
            pkgs.go_1_23
            pkgs.gopls
            pkgs.gotools
            pkgs.sqlite
          ];
          shellHook = ''
            echo "[backend] $(go version)"
          '';
        };

        devShells.frontend = pkgs.mkShell {
          name = "portal-frontend";
          packages = [
            pkgs.nodejs_22
            pkgs.nodePackages.typescript
            pkgs.nodePackages.typescript-language-server
          ];
          shellHook = ''
            echo "[frontend] node $(node --version)"
          '';
        };
      });
}

Each subdirectory gets its own .envrc pointing at the correct output.

Save as backend/.envrc:

# backend/.envrc
use flake ..#backend

Save as frontend/.envrc:

# frontend/.envrc
use flake ..#frontend

Allow both, then switch between them:

direnv allow backend
direnv allow frontend
cd backend

Output:

direnv: loading ~/projects/portal/backend/.envrc
direnv: using flake ..#backend
direnv: nix-direnv: using cached dev shell
[backend] go version go1.23.4 linux/amd64
direnv: export +AR +AS +CC … +buildInputs
cd ../frontend

Output:

direnv: unloading
direnv: loading ~/projects/portal/frontend/.envrc
direnv: using flake ..#frontend
direnv: nix-direnv: using cached dev shell
[frontend] node v22.9.0
direnv: export +NODE_PATH … +buildInputs

The Go tools are absent from the frontend shell and vice versa. No manual activation or deactivation is needed.

Case 4: Caching and GC safety

Every time nix-direnv builds a devShell it writes a symlink under .direnv/ that acts as a Nix GC root. This prevents nix-collect-garbage -d from deleting the store paths your shell depends on.

Inspect the structure after the first use flake:

ls -la ~/projects/desk-metrics/.direnv/

Output:

total 16
drwxr-xr-x 3 ops ops 4096 Sep  9 10:12 .
drwxr-xr-x 8 ops ops 4096 Sep  9 10:12 ..
drwxr-xr-x 2 ops ops 4096 Sep  9 10:12 flake-inputs
lrwxrwxrwx 1 ops ops   80 Sep  9 10:12 flake-profile -> /nix/store/…-desk-metrics-profile
lrwxrwxrwx 1 ops ops   80 Sep  9 10:12 flake-profile-1-link -> /nix/store/…-desk-metrics-profile
ls -la ~/projects/desk-metrics/.direnv/flake-inputs/

Output:

total 8
drwxr-xr-x 2 ops ops 4096 Sep  9 10:12 .
drwxr-xr-x 3 ops ops 4096 Sep  9 10:12 ..
lrwxrwxrwx 1 ops ops   83 Sep  9 10:12 nixpkgs -> /nix/store/…-source

Now run garbage collection:

nix-collect-garbage -d

Output:

finding garbage collector roots...
removing stale link from '/nix/var/nix/gcroots/per-user/ops/…' to '…'
deleting garbage...
deleting '/nix/store/…-some-old-package'
…
2.31 GiB freed

Your devShell store paths survive because .direnv/flake-profile-1-link is registered as a GC root. Re-enter the directory:

cd ~/projects/desk-metrics

Output:

direnv: loading ~/projects/desk-metrics/.envrc
direnv: using flake
direnv: nix-direnv: using cached dev shell
desk-metrics shell: go version go1.23.4 linux/amd64
direnv: export +AR …

The shell is still instant — nothing was collected.

Add .direnv/ to the project’s .gitignore:

# .gitignore
.direnv/

The symlinks are machine-local. Committing them would break other developers’ paths.

The trap

Using plain use nix instead of use flake

direnv ships with a built-in use_nix helper for legacy shell.nix files. A common mistake is writing .envrc like this for a flake project:

# .envrc  ← WRONG for a flake project
use nix

use nix has no cache. It invokes nix-shell on every directory entry. For a devShell with many dependencies, that means several seconds of evaluation on every cd.

Symptom:

cd ~/projects/desk-metrics

Output:

direnv: loading ~/projects/desk-metrics/.envrc
direnv: using nix
[4.2s pause]
direnv: export +AR +AS +CC …

Fix: use use flake (provided by nix-direnv):

# .envrc  ← correct
use flake

Result:

cd ~/projects/desk-metrics

Output:

direnv: loading ~/projects/desk-metrics/.envrc
direnv: using flake
direnv: nix-direnv: using cached dev shell
direnv: export +AR +AS +CC …

The difference is a cache layer. use flake stores the built environment as a profile and reloads it by reading symlinks — no Nix evaluation occurs on a cache hit.

A related mistake is forgetting to re-run direnv allow after editing .envrc. direnv blocks evaluation of changed files until you explicitly approve them:

direnv: error ~/projects/desk-metrics/.envrc is blocked. Run `direnv allow` to approve its content.

This is a security feature, not a bug. Run direnv allow after every intentional .envrc change.

The boring rule

  • Commit .envrc with use flake (or use flake .#<name>) plus watch_file flake.lock. A lock bump must rebuild the cache. .envrc.local / .env stay gitignored (dotenv_if_exists).
  • Gitignore .direnv/. Its contents are machine-local GC roots and cached profiles.
  • Run direnv allow explicitly every time .envrc changes. Treat it as a checkpoint: you reviewed the file and approved it.
  • Never use use nix in a flake project. The built-in has no cache and no GC safety. Always use the nix-direnv use flake.
  • Install via Home Manager, not ad-hoc. The programs.direnv module wires the shell hook correctly for bash, zsh, and fish without manual init-file edits.
  • One .envrc per shell boundary. Subdirectory .envrc files can reference the parent flake with use flake ..#<name>. Do not duplicate flake outputs.

Try this

  1. Baseline timing. In an existing flake project, write an .envrc with use nix and measure the cold entry time with time (cd project && cd ..). Then switch to use flake, run direnv allow, and measure again. Record both numbers and note the difference.

  2. Named-shell workspace. Create a ~/projects/portal repository with the two-shell flake from Case 3. Add backend/.envrc and frontend/.envrc. Verify that which go returns a path only inside backend/ and which node returns a path only inside frontend/.

  3. GC resilience. Enter a direnv-managed project and note a store path from echo $buildInputs | tr ' ' '\n' | head -1. Run nix-collect-garbage -d. Confirm the store path still exists with ls /nix/store/<path>. Then delete .direnv/ manually, re-enter the directory, and observe nix-direnv rebuilding from scratch.

  4. Audit your trust. Run cat ~/.local/share/direnv/allow/* to list all allowed .envrc hashes on your workstation. For each corresponding directory, open .envrc and confirm the content matches what you expect. Revoke any stale entries with direnv deny <path>.