direnv and project shells
direnv and project shells
Goal: Keep project toolchains in flake devShells, load them automatically with direnv (use flake), and stop stuffing every language toolchain into system or HM profiles.
Why this chapter exists
Two failure modes dominate Nix workstations:
- Profile bloat: Go, Node, Rust, Python, JDK all in
home.packagesforever - Snowball shells: random
nix-shell -pwith no lock, unreproducible on Monday
The modern fix:
| Layer | Holds |
|---|---|
| NixOS / HM | OS, editors, generic CLI, user services |
Project flake devShells |
Compilers, linters, project CLIs |
| direnv | Enters/exits the shell when you cd |
Project pinning is a first-class skill—independent of host configuration.
Theory 1 — devShells in a flake
Minimal project flake (can be separate from the NixOS flake):
# ~/lab/demo-app/flake.nix
{
description = "demo app toolchain";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
go_1_24 # adjust to versions available on your pin
gopls
git
];
shellHook = ''
export DEMO_APP=1
echo "entered demo-app devShell"
'';
};
};
}Enter manually:
cd ~/lab/demo-app
nix developMulti-system sketch
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in {
devShells = forAllSystems (system:
let pkgs = nixpkgs.legacyPackages.${system}; in {
default = pkgs.mkShell {
packages = with pkgs; [ git jq ];
};
});
}Why not always put shells in the NixOS flake?
You can expose devShells from the host flake, but project repos usually own their own flake so clones on other machines work without your entire OS config. Host flake remains for nixosConfigurations (+ checks). Host-level shells for ops tools (nil, nixpkgs-fmt) are still fine.
Theory 2 — direnv + nix-direnv
direnv loads environment variables when you enter a directory with .envrc.
nix-direnv (recommended) caches Nix flake shells efficiently so every cd is not a full re-eval disaster.
HM installation pattern
# homes/alice/direnv.nix
{ pkgs, ... }:
{
programs.direnv = {
enable = true;
nix-direnv.enable = true;
enableBashIntegration = true;
# enableZshIntegration / enableFishIntegration as needed
};
}Rebuild host/HM, then new shells pick up the hook (eval "$(direnv hook bash)" is usually wired by the module).
Verify hook
command -v direnv
type direnv
# open a new login shell if neededTheory 3 — .envrc with use flake
In the project:
# ~/lab/demo-app/.envrc
use flakeAllow once:
direnv allowBehavior:
| Action | Result |
|---|---|
cd into project |
Loads devShell env |
cd out |
Unloads |
| flake/lock change | direnv reloads (may rebuild) |
Named shells
# use flake .#ci
use flakeIf you define devShells.${system}.ci, you can select it—check current nix-direnv / direnv-stdlib docs for exact syntax on your pin.
.envrc is not for secrets
Do not put API tokens in .envrc committed to git. Use:
- Untracked
.envrc.localpatterns (with care) - Secret managers / sops (services/security part)
- Runtime private files excluded by git
.envrc.local
.env
Theory 4 — System packages vs project shells
| Put in HM/system | Put in devShell |
|---|---|
git, neovim, htop |
go, cargo, node, python venv tools |
| direnv itself | project linters pinned to repo |
| SSH client | DB client only for one service’s repo |
Test: if two projects need different major versions, it belongs in the project shell.
Theory 5 — Pinning discipline for project flakes
Project flake.lock should be committed for apps you ship or share. For private throwaways, still locking saves future you.
Update project nixpkgs independently of the host flake—on purpose. Host OS pin ≠ every app pin.
| Host flake | Project flake |
|---|---|
| Stability for OS modules | May need newer compiler |
| Selective update cadence | Can track unstable carefully |
| Shared by all services | Local to one repo |
Theory 6 — nix develop vs nix shell vs HM packages
| Command | Role |
|---|---|
nix develop |
Flake devShell (hooks, packages, inputsFrom) |
nix shell nixpkgs#foo |
Ad-hoc package on PATH |
home.packages |
Always-on user profile |
direnv use flake |
Automates develop-class env |
inputsFrom (awareness)
pkgs.mkShell {
inputsFrom = [ self.packages.${system}.default ];
packages = [ pkgs.jq ];
};Pulls build inputs from a package—useful when packaging and developing the same project.
Theory 7 — shellHook hygiene
| Do | Don’t |
|---|---|
Export needed PROJECT_ROOT |
Print banners that annoy CI |
Set LANG if required |
Embed secrets |
| Keep hooks idempotent | Assume interactive-only blindly |
shellHook = ''
export PROJECT_ROOT="$PWD"
# echo only if interactive:
# [ -n "$PS1" ] && echo "devShell ready"
'';Theory 8 — Host flake devShell for Nix ops
Optional host-level shell (not a substitute for project shells):
# in host flake outputs
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [ nixpkgs-fmt nil git jq ];
};cd /path/to/nixos-config
nix developUse for editing the OS flake; keep language toolchains in app repos.
Worked example — Multi-tool Python shell
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
python3
python3Packages.pytest
ruff
];
shellHook = ''
export PYTHONPATH="$PWD/src''${PYTHONPATH:+:$PYTHONPATH}"
'';
};# .envrc
use flakeWorked example — Isolation proof layout
~/lab/proj-a/ # flake with cowsay only
~/lab/proj-b/ # flake with hello only
cd ~/lab/proj-a && command -v cowsay
cd ~/lab/proj-b && command -v hello
cd ~ && command -v cowsay; command -v hello # both missing if thinnedExercises
Exercise 1 — Install direnv via HM
Add programs.direnv with nix-direnv.enable = true. Rebuild. Verify:
command -v direnv
direnv versionOpen a new login shell if hooks are missing.
Exercise 2 — Create a project flake
mkdir -p ~/lab/demo-app
cd ~/lab/demo-app
git init
# write flake.nix with mkShell and 2–3 tools
nix flake lock
nix develop -c which gitExercise 3 — Wire direnv
echo 'use flake' > .envrc
direnv allow
cd ..
cd demo-app
echo "PATH head:"; echo "$PATH" | tr ':' '\n' | head
type python3 2>/dev/null || type go 2>/dev/null || type hello 2>/dev/null || trueConfirm leaving the directory drops the env.
Exercise 4 — Prove version isolation
Make two tiny projects with different tools (e.g. one with hello only, one with cowsay only). cd between them; show different PATH entries.
Exercise 5 — Thin the profile (careful)
List home.packages / system packages. Move one language toolchain from always-on profile into a project shell. Rebuild HM. Confirm the tool is gone from bare shells but present under direnv.
Do not remove your editor without a plan.
Exercise 6 — Document the workflow
Journal template:
cd project → direnv loads flake lock pin → tools available
change flake → direnv reload → maybe download/build
secrets → never in committed .envrc
Exercise 7 — Commit project lock
cd ~/lab/demo-app
git add flake.nix flake.lock .envrc
git status
# ensure .env / secrets not stagedExercise 8 — Reload after lock bump
nix flake update nixpkgs
# leave and re-enter directory or: direnv reloadExercise 9 — Quiet shellHook
If your hook is noisy, gate prints on interactive shells. Re-enter and confirm.
Exercise 10 — Host ops shell
Add a minimal devShells to the host flake with nixpkgs-fmt or nil. nix develop from the host repo.
Exercise 11 — Ad-hoc vs project
nix shell nixpkgs#jq -c jq --versionWrite one sentence: when ad-hoc is OK vs when a project flake is required.
Exercise 12 — gitignore hygiene
Ensure .direnv/ is ignored in the project (and ideally global gitignore).
.direnv/
result
result-*
.env
.envrc.local
Exercise 13 — Failure: allow missing
# new clone simulation
rm -f .envrc # careful
echo 'use flake' > .envrc
# without allow, direnv blocks — observe message
direnv allowExercise 14 — Boundary table
Fill a 6-row table: tool → HM / system / project shell. Commit to notes.
Common gotchas
| Symptom / mistake | What to do |
|---|---|
| direnv not hooking | New shell; enable integration for your shell |
use flake blocked |
direnv allow |
Slow every cd |
Ensure nix-direnv enabled |
| Wrong system arch in flake | Match system to the machine |
Secrets in .envrc committed |
Rotate; use gitignored local files / sops |
| Host flake-only shells | Fine for ops; projects should still travel |
shellHook prints annoyingly |
Keep hooks quiet in shared repos |
Forgotten flake.lock in project |
Commit it |
| Tool still in home.packages | Thinned incompletely |
.direnv not ignored |
Add to gitignore |
| Expecting macOS path on Linux CI | Pin systems explicitly |
Checkpoint
- direnv + nix-direnv via HM
- At least one project with
devShells.default+ lock .envrcwithuse flakeallowed- Enter/leave directory toggles tools
- One toolchain removed from always-on profile (or plan written)
- Secret policy for env files stated
- Project lock committed
- Understand host ops shell vs project shell
Journal (optional)
Write three personal gotchas before continuing.