Auto-Loading Project Environments With direnv
A developer opens a new terminal tab, runs terraform plan, and gets Error: Unsupported Terraform Core version because the system Terraform ran instead of the project's pinned one — they forgot to run nix develop in that tab. Or direnv is installed but every cd prints direnv: error /home/dev/shop/.envrc is blocked. Run 'direnv allow' to approve its content. direnv makes a reproducible environment automatic: entering the directory loads it, leaving unloads it. This page sets it up properly for a project, including caching and IDE integration, as part of reproducible dev shells with Nix, Devbox and direnv.
direnv works with any environment source — Nix flakes, Devbox, mise, Python virtualenvs, plain export lines — so the same approach applies whichever toolchain manager the repository uses.
Diagnostic
Check whether the hook is installed, whether the project's .envrc is allowed, and which tool a new shell resolves:
#!/usr/bin/env bash
set -euo pipefail
direnv version
grep -n 'direnv hook' ~/.zshrc ~/.bashrc 2>/dev/null || echo "direnv hook not found in shell rc files"
direnv status | grep -E 'Found RC path|Found RC allowed|Loaded RC path' || true
command -v terraform
terraform version | head -1
Expected bad output:
2.34.0
direnv hook not found in shell rc files
Found RC path /home/dev/shop/.envrc
Found RC allowed false
/usr/local/bin/terraform
Terraform v1.5.7
direnv is installed but the shell hook is missing, so nothing runs on cd; the .envrc exists but was never allowed; and the system Terraform is on PATH.
Root cause
direnv has two parts: a binary that evaluates .envrc files, and a shell hook that calls it before each prompt to compare the current directory with the last one. Without the hook, the binary never runs. With the hook, direnv refuses to execute any .envrc it has not seen before, or whose contents changed since the last approval, because .envrc is arbitrary shell code pulled in with every git pull — the allow step is a security control, not an annoyance. Once allowed, direnv evaluates the file in a subshell, captures the resulting environment diff and applies it to your shell. Without caching, use flake re-evaluates the flake on each change of directory, which can take several seconds; nix-direnv caches the result and makes it instant.
IDEs add one more layer. A terminal inside VS Code has the hook, but the IDE's language servers, debuggers and test runners are launched by the IDE process, which never ran .envrc. They see the system tools unless an extension loads direnv's environment into the IDE itself.
Resolution
- Install the hook for the shell you use and restart it:
#!/usr/bin/env bash
set -euo pipefail
case "${SHELL##*/}" in
zsh) grep -q 'direnv hook zsh' ~/.zshrc || echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc ;;
bash) grep -q 'direnv hook bash' ~/.bashrc || echo 'eval "$(direnv hook bash)"' >> ~/.bashrc ;;
fish) mkdir -p ~/.config/fish/conf.d && echo 'direnv hook fish | source' > ~/.config/fish/conf.d/direnv.fish ;;
esac
echo "restart the shell or open a new tab"
- Install nix-direnv so flake environments are cached and registered as garbage-collection roots:
#!/usr/bin/env bash
set -euo pipefail
nix profile install nixpkgs#nix-direnv
mkdir -p ~/.config/direnv
grep -q nix-direnv ~/.config/direnv/direnvrc 2>/dev/null || \
echo 'source $HOME/.nix-profile/share/nix-direnv/direnvrc' >> ~/.config/direnv/direnvrc
- Write a small, reviewable
.envrc:
#!/usr/bin/env bash
set -euo pipefail
cat > .envrc <<'EOF'
watch_file flake.nix flake.lock
use flake
export COMPOSE_PROJECT_NAME=shop
dotenv_if_exists .env.local
EOF
direnv allow
watch_file makes direnv reload when the flake changes. dotenv_if_exists layers personal, git-ignored overrides without anyone editing the shared file. For Devbox, replace use flake with the output of devbox generate direnv; for mise, use use mise.
- Connect the IDE. In VS Code, install the
mkhl.direnvextension; in JetBrains IDEs, the Direnv integration plugin. Both load the.envrcenvironment into the IDE process so language servers resolve the pinned tools.
Expected output
$ cd ~/src/shop
direnv: loading ~/src/shop/.envrc
direnv: using flake
direnv: nix-direnv: using cached dev shell
direnv: export +COMPOSE_PROJECT_NAME +IN_NIX_SHELL ~PATH
$ command -v terraform
/nix/store/0pqh…-terraform-1.8.5/bin/terraform
$ cd ..
direnv: unloading
$ command -v terraform
/usr/local/bin/terraform
Entering the directory loads the pinned Terraform from the Nix store in well under a second; leaving restores the system one.
The export +COMPOSE_PROJECT_NAME ~PATH line is worth reading every time: + marks variables direnv added, - marks variables it removed, and ~ marks variables it modified. If a variable you expect is missing from that line, the .envrc did not set it, which is faster to spot than debugging the tool that needed it. Subdirectories inherit the environment — cd api/ keeps it loaded — and a nested .envrc in a subdirectory can add service-specific settings on top with source_up, which is useful in monorepos where one service needs an extra tool or variable.
A final check is the IDE. Open the project, then open the IDE's own integrated terminal and a run configuration for the test suite, and print the tool path in each. All three should point into the Nix store or .devbox. When only the IDE disagrees, the direnv extension is not active for that workspace — usually because it was installed in a different VS Code profile or in the local context of a remote session.
Prevention
- Check the environment in
make doctor. A test that the resolved tool path starts with/nix/store/(or.devbox/) turns "I forgot to allow direnv" into an actionable message:
#!/usr/bin/env bash
set -euo pipefail
case "$(command -v terraform)" in
/nix/store/*|*/.devbox/*) echo "project toolchain active" ;;
*) echo "project toolchain not loaded: install the direnv hook and run 'direnv allow'"; exit 1 ;;
esac
Review
.envrcchanges like code. Because every developer runs it, a CODEOWNERS entry for.envrcis reasonable. Keep it short so reviews are easy.Never put secrets in
.envrc. It is committed. Load secrets from a git-ignored file withdotenv_if_exists, or from a secret manager as described in pulling dev secrets from 1Password at startup.
Platform caveats
macOS: the default login shell is zsh; the hook goes in
~/.zshrc. Terminal apps that start login shells read~/.zprofilefirst, which is fine — the hook in~/.zshrcstill runs for interactive shells.
WSL2: install direnv inside the distribution. VS Code's Remote WSL extension runs the direnv extension on the Linux side, so install it in the WSL context, not locally on Windows.
Apple Silicon (ARM64): nothing architecture-specific in direnv; flake evaluation uses the native
aarch64-darwinoutputs.
Rollback
Remove the hook line from the shell rc file and delete .envrc; the environment simply stops loading automatically, and nix develop or devbox shell still work by hand:
#!/usr/bin/env bash
set -euo pipefail
sed -i.bak '/direnv hook/d' ~/.zshrc
git rm --cached .envrc 2>/dev/null || true
rm -f .envrc
Frequently Asked Questions
Why do I have to run direnv allow again after pulling?
direnv records a hash of each allowed .envrc. When a pull changes the file, the hash no longer matches and direnv blocks it until you review and allow the new version. This prevents a malicious or mistaken change from executing silently.
Is use flake slow?
Without caching it re-evaluates the flake whenever the directory changes, which can take seconds. With nix-direnv installed, the evaluated environment is cached and reused until flake.nix or flake.lock changes.
Can I have personal environment variables without changing the shared .envrc?
Yes. Put them in a git-ignored .env.local and load it with dotenv_if_exists .env.local, or create .envrc.local and add source_env_if_exists .envrc.local to the shared file.
Does direnv work in CI?
It can, with direnv exec . <command>, but CI is simpler with nix develop --command or devbox run directly. direnv is primarily an interactive convenience.