Automatic Dev Shells with direnv
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:
- Builds the devShell once and caches the result in
/nix/store. - Creates a GC-root symlink at
.direnv/flake-profileso thatnix-collect-garbage -dcannot evict the store paths the shell depends on. - Re-evaluates only when
flake.nix,flake.lock, or.envrcchanges — not on everycd.
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 switchOutput:
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/deskOutput:
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.localAllow and enter:
cd ~/projects/desk-metrics
direnv allowOutput:
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-metricsOutput:
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 goimportsOutput:
/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 ..#backendSave as frontend/.envrc:
# frontend/.envrc
use flake ..#frontendAllow both, then switch between them:
direnv allow backend
direnv allow frontend
cd backendOutput:
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 ../frontendOutput:
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 -dOutput:
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-metricsOutput:
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 nixuse 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-metricsOutput:
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 flakeResult:
cd ~/projects/desk-metricsOutput:
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
.envrcwithuse flake(oruse flake .#<name>) pluswatch_file flake.lock. A lock bump must rebuild the cache..envrc.local/.envstay gitignored (dotenv_if_exists). - Gitignore
.direnv/. Its contents are machine-local GC roots and cached profiles. - Run
direnv allowexplicitly every time.envrcchanges. Treat it as a checkpoint: you reviewed the file and approved it. - Never use
use nixin a flake project. The built-in has no cache and no GC safety. Always use the nix-direnvuse flake. - Install via Home Manager, not ad-hoc. The
programs.direnvmodule wires the shell hook correctly for bash, zsh, and fish without manual init-file edits. - One
.envrcper shell boundary. Subdirectory.envrcfiles can reference the parent flake withuse flake ..#<name>. Do not duplicate flake outputs.
Try this
Baseline timing. In an existing flake project, write an
.envrcwithuse nixand measure the cold entry time withtime (cd project && cd ..). Then switch touse flake, rundirenv allow, and measure again. Record both numbers and note the difference.Named-shell workspace. Create a
~/projects/portalrepository with the two-shell flake from Case 3. Addbackend/.envrcandfrontend/.envrc. Verify thatwhich goreturns a path only insidebackend/andwhich nodereturns a path only insidefrontend/.GC resilience. Enter a direnv-managed project and note a store path from
echo $buildInputs | tr ' ' '\n' | head -1. Runnix-collect-garbage -d. Confirm the store path still exists withls /nix/store/<path>. Then delete.direnv/manually, re-enter the directory, and observe nix-direnv rebuilding from scratch.Audit your trust. Run
cat ~/.local/share/direnv/allow/*to list all allowed.envrchashes on your workstation. For each corresponding directory, open.envrcand confirm the content matches what you expect. Revoke any stale entries withdirenv deny <path>.