direnv and project shells

Updated

July 30, 2026

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:

  1. Profile bloat: Go, Node, Rust, Python, JDK all in home.packages forever
  2. Snowball shells: random nix-shell -p with 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 develop

Multi-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 needed

Theory 3 — .envrc with use flake

In the project:

# ~/lab/demo-app/.envrc
use flake

Allow once:

direnv allow

Behavior:

Action Result
cd into project Loads devShell env
cd out Unloads
flake/lock change direnv reloads (may rebuild)

Named shells

# use flake .#ci
use flake

If 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.local patterns (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 develop

Use 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 flake

Worked 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 thinned

Exercises

Exercise 1 — Install direnv via HM

Add programs.direnv with nix-direnv.enable = true. Rebuild. Verify:

command -v direnv
direnv version

Open 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 git

Exercise 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 || true

Confirm 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 staged

Exercise 8 — Reload after lock bump

nix flake update nixpkgs
# leave and re-enter directory or: direnv reload

Exercise 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 --version

Write 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 allow

Exercise 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
  • .envrc with use flake allowed
  • 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.