Per-project shell environments with direnv
I work across a handful of projects in a day. Each one has its own database URL, API keys, and sometimes its own Node version. Sourcing .env files by hand is tedious, and exporting secrets into my global shell is a bad idea.
direnv loads a per-directory shell environment when I cd in, and unloads it when I leave. That's the whole pitch.
What direnv does
direnv hooks into your shell. Every time you change directory, it walks up the tree looking for an .envrc file. If it finds one, and you've explicitly allowed it, direnv runs the file and injects the resulting environment into your current shell.
Leave the directory, and those variables are gone. No manual unset. No stale DATABASE_URL pointing at the wrong project.
Installing it
On macOS with Homebrew:
brew install direnv
Then hook it into your shell. For zsh, add this to the bottom of ~/.zshrc:
eval "$(direnv hook zsh)"
For bash, use ~/.bashrc and direnv hook bash. Restart your shell.
Basic usage
Drop an .envrc into a project:
export DATABASE_URL="postgres://localhost:5432/blog"
export NODE_ENV="development"
cd into the directory and direnv will refuse to run it until you approve:
direnv: error .envrc is blocked. Run `direnv allow` to approve its content
Run direnv allow. From now on, every time you enter that directory, those variables are exported. Leave, and they're unset.
The approval is the safety layer. If someone else edits the .envrc, whether a teammate, a dependency, or a malicious patch, direnv blocks it again until you re-approve.
Loading an existing .env file
Most of my projects already have a .env file that other tools read. I don't want to duplicate values. .envrc can source them:
dotenv
That's it. direnv reads .env and exports everything. If the file doesn't exist, direnv complains. Use dotenv_if_exists for optional loading.
You can also point at a different file:
dotenv .env.local
Keeping secrets out of git
Add .envrc to your global gitignore, not the repo's:
.envrc
.direnv/
The repo stays clean for teammates who don't use direnv. Your per-machine setup stays local.
If the .envrc itself is safe to share (no secrets, just dotenv and tooling setup), commit it. Put the actual secrets in .env.local and gitignore that instead.
What else it can do
direnv isn't just for environment variables. The .envrc is a shell script, so anything you'd put in .bashrc works:
- Add a project-local
bin/to PATH.PATH_add binputs./binin front, scoped to this directory. - Pin a Node version.
use node 22.11.0via the node layout. - Activate a Python virtualenv.
layout python3creates and activates one per project.
I mostly use it for environment variables and the occasional PATH_add. The rest is there when I need it.
Takeaways
- Secrets stay scoped to the project that needs them.
- No more
source .envmuscle memory. - The
direnv allowgate means you approve every change before it runs.
If you already juggle a few projects with different env files, direnv pays for itself in a week.