Use direnv for Per-Project Environments
Learn to use direnv to automatically load per-project environment variables, with integration guides for pyenv, nvm, mise, and asdf.
Before you start
- ▸bash, zsh, or fish shell configured with a personal RC file
- ▸Basic familiarity with exporting environment variables in shell scripts
- ▸A version manager (nvm, pyenv, or mise) already installed if you intend to use those integrations
direnv automatically loads and unloads environment variables when you change directories. Instead of sourcing activation scripts by hand or polluting your global shell environment, you drop a .envrc file in a project root and direnv handles the rest. It works with any shell, any language toolchain, and composes cleanly with version managers like nvm, pyenv, mise, and asdf.
Install direnv
Install from your distribution's package manager. The package name is consistently direnv across distros.
Debian / Ubuntu:
sudo apt install direnv
Fedora / RHEL 9+ / Rocky:
sudo dnf install direnv
Arch:
sudo pacman -S direnv
If your distro ships an older version and you need a recent release, the official install script fetches the latest binary directly:
curl -sfL https://direnv.net/install.sh | bash
Hook direnv Into Your Shell
direnv requires a shell hook that runs after every prompt. Add exactly one line to your shell's RC file, then restart the shell or source the file.
bash — append to ~/.bashrc:
eval "$(direnv hook bash)"
zsh — append to ~/.zshrc:
eval "$(direnv hook zsh)"
fish — append to ~/.config/fish/config.fish:
direnv hook fish | source
After saving, reload:
source ~/.bashrc # or ~/.zshrc, or restart your terminal
Create Your First .envrc
Navigate to a project directory and create a .envrc file. This is a plain shell script that direnv executes in a sandbox and exports the resulting variables into your shell.
cd ~/projects/myapp
cat > .envrc <<'EOF'
export APP_ENV=development
export DATABASE_URL=postgres://localhost/myapp_dev
export PORT=3000
EOF
Allow the File
direnv refuses to load a .envrc it hasn't seen before. This is a security measure — it prevents a cloned repo from silently executing arbitrary code in your shell. After reviewing the file, explicitly allow it:
direnv allow .
You'll see a message like direnv: loading ~/projects/myapp/.envrc. Leave the directory and return to confirm load/unload works:
cd /tmp && echo $APP_ENV # empty
cd ~/projects/myapp && echo $APP_ENV # development
Any time you edit .envrc, direnv blocks it again until you re-run direnv allow. Use direnv deny . to revoke a directory you previously allowed.
The stdlib: Built-in Helpers
direnv ships a standard library of shell functions available inside every .envrc. These cover the most common patterns without extra tooling.
dotenv— load a.envfile (key=value format) into the environmentlayout python3— create and activate a virtualenv automaticallylayout node— prependnode_modules/.bintoPATHpath_add PATH ./bin— safely prepend a local directory toPATHsource_env .envrc.local— include another envrc file (useful for secrets not committed to git)
A Python project that uses a local virtualenv looks like this:
cat > .envrc <<'EOF'
layout python3
export DJANGO_SETTINGS_MODULE=config.settings.dev
EOF
direnv allow .
direnv creates .direnv/python-3.x.x/, builds a virtualenv there, and activates it on entry. You never call source .venv/bin/activate again.
Integrating With pyenv
If you manage Python versions with pyenv, combine it with the layout pyenv helper (available in newer direnv builds) or call pyenv local alongside layout python3:
cat > .envrc <<'EOF'
export PYENV_VERSION=3.11.9
layout python3
EOF
direnv allow .
direnv will pick up whichever Python pyenv resolves for that version string. Make sure pyenv's shims are in PATH before the direnv hook runs — your ~/.bashrc or ~/.zshrc pyenv initialization should come before the eval "$(direnv hook ...)" line.
Integrating With nvm
nvm is a shell function, not a binary, which makes it slightly awkward to call from inside .envrc. The recommended pattern uses use nvm from direnv-nvm, or you can source nvm directly:
cat > .envrc <<'EOF'
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh"
nvm use 20
EOF
direnv allow .
This works but adds latency because sourcing nvm.sh is slow. The faster modern approach is to switch to mise (see below), which was built with direnv integration in mind.
Integrating With mise (or asdf)
mise is a fast, modern runtime version manager (compatible with .tool-versions files used by asdf). It has first-class direnv integration.
mise + direnv
After installing mise, activate its direnv integration once:
mise settings set experimental true
mise direnv activate
Then in any project with a .tool-versions or mise.toml file, add a single line to .envrc:
cat > .envrc <<'EOF'
use mise
EOF
direnv allow .
mise reads your .tool-versions (e.g., nodejs 20.14.0, python 3.12.3) and shims the correct binaries into your PATH for that directory only.
asdf (classic)
For asdf users, the pattern is similar — source the asdf script inside .envrc:
cat > .envrc <<'EOF'
source "$(brew --prefix asdf)/libexec/asdf.sh" 2>/dev/null \
|| source "$HOME/.asdf/asdf.sh"
use asdf
EOF
direnv allow .
Keep Secrets Out of Version Control
Add .envrc to your .gitignore if it contains secrets, or use a two-file pattern: commit a .envrc with non-sensitive settings and load a gitignored .envrc.local for secrets:
# .envrc (committed)
layout python3
export APP_ENV=development
source_env_if_exists .envrc.local
# .envrc.local (gitignored)
export SECRET_KEY=s3cr3t
export STRIPE_API_KEY=sk_test_...
echo ".envrc.local" >> .gitignore
Verify Everything Is Working
direnv status
Output (will vary) shows the found .envrc path, whether it is allowed, and the last load time. Use direnv reload to force a fresh load without leaving the directory.
Troubleshooting
Variables aren't loading
- Confirm the hook line is in your RC file and your shell was reloaded.
- Run
direnv status— a "not allowed" state means you needdirenv allow .. - Check for syntax errors:
bash -n .envrc(orzsh -n).
"command not found" for nvm or pyenv inside .envrc
Shell functions defined in your RC file are not available in direnv's sandbox. You must source the relevant init script explicitly inside .envrc, or use mise/asdf which are real binaries.
Slow shell prompt after adding direnv
Usually caused by sourcing a heavy script (nvm.sh, conda activate) inside .envrc. Switch to mise for version management — its direnv path injection is near-instant compared to sourcing nvm.
direnv blocks after git pull
If a teammate changed .envrc, direnv blocks it deliberately. Review the diff (git diff HEAD~1 .envrc), then direnv allow . once you're satisfied.
Frequently asked questions
- Is it safe to commit .envrc to git?
- It depends on what's in it. Layout directives and non-sensitive exports are fine to commit. API keys, passwords, and tokens belong in a gitignored .envrc.local loaded via source_env_if_exists.
- Why does direnv block my .envrc after I edit it?
- direnv re-blocks any file that changes as a security measure, preventing a git pull from silently executing new code. Review the change and run direnv allow . again to re-authorize it.
- Can I use direnv with conda environments?
- Yes. Source the conda.sh init script inside .envrc and call conda activate myenv. Be aware this adds noticeable latency; mise with a Python plugin is faster for most use cases.
- Does direnv work inside Docker or CI pipelines?
- direnv is primarily a developer workstation tool. In CI, set environment variables through your pipeline's native secret/env mechanism instead. direnv can technically run in CI but adds unnecessary complexity.
- What is the difference between mise and asdf for use with direnv?
- Both read .tool-versions files and manage language runtime versions. mise is a single Rust binary that is significantly faster to activate and has a dedicated use mise directive for direnv. asdf is the original tool and has broader community plugins but slower shell startup.
Related guides
Bash Arrays and Associative Arrays
Master bash indexed and associative arrays: declaration, element access, looping, mapfile, namerefs, and practical patterns for real scripting work.
Bash Functions and Variable Scoping
Master Bash function scoping with local variables, source-based libraries, correct use of return codes, and array passing techniques including namerefs.
Bash Loops: for, while and until
Learn all three Bash loop types — for, while, and until — with practical, copy-paste examples covering file iteration, counting, polling, and safe line reading.
Bash Scripting for Beginners
Learn Bash scripting from scratch: shebang lines, variables, conditionals, loops, and arguments, plus a real backup script to tie it all together.