direnv: Per-Directory Environment Variables That Don't Pollute Your Shell

2026-05-08

Every developer has done this dance: export AWS_PROFILE=client-foo, work for an hour, switch to another project, forget to unset, accidentally deploy to the wrong account. Or you maintain a .env file and source it manually. Or worse — you stuff client secrets into ~/.bashrc and they leak into every subshell, every ps dump, every screen share.

direnv solves this with one trick: it hooks into your shell prompt and loads/unloads environment variables based on the directory you're in. Walk into a project, vars appear. Walk out, they vanish. No sourcing, no remembering, no leaks.

Install and hook it once:

$ apt install direnv   # or brew, or pacman
$ echo 'eval "$(direnv hook bash)"' >> ~/.bashrc

Then in any project directory, drop a .envrc file:

$ cd ~/work/client-foo
$ cat > .envrc <<'EOF'
export AWS_PROFILE=client-foo
export DATABASE_URL=postgres://localhost/foo_dev
export PATH="$PWD/bin:$PATH"
EOF
$ direnv allow
direnv: loading ~/work/client-foo/.envrc
direnv: export +AWS_PROFILE +DATABASE_URL ~PATH

The direnv allow step is the security model — direnv refuses to execute an .envrc until you explicitly trust it. Edit the file, and you must allow it again. This means a git pull can never silently inject env vars into your shell.

The killer feature is the stdlib. .envrc is just bash, but direnv ships helper functions that handle 90% of what you'd write by hand:

# Load .env file (the simple case)
dotenv

# Load only if file exists, no error otherwise
dotenv_if_exists .env.local

# Add bin/ to PATH relative to .envrc location
PATH_add bin
PATH_add node_modules/.bin

# Walk up the tree and source the first match
source_up

# Require a var to be set, fail loudly if not
strict_env
export API_KEY=${API_KEY:?must be set in ~/.envrc.private}

The source_up trick is gold for monorepos: put shared vars in the repo root .envrc, and per-service overrides in subdirectory .envrc files. Each service gets the union, automatically.

It also integrates with version managers without their shell hooks:

use python 3.11        # creates/activates a venv in .direnv/
use node 20            # via nodenv/asdf
use nix                # full nix-shell environment, but only here
layout python          # auto-creates virtualenv, no manual activation

That last one is why I haven't typed source venv/bin/activate in years. cd into a Python project, venv activates. cd out, it deactivates. The venv lives in .direnv/ which you gitignore.

One pattern I use constantly — splitting public config from secrets:

# .envrc (committed)
export DATABASE_HOST=db.internal
export S3_BUCKET=foo-prod
source_env_if_exists .envrc.private

# .envrc.private (gitignored)
export AWS_SECRET_ACCESS_KEY=...
export STRIPE_SECRET_KEY=...

The team shares the public file via git; secrets stay local. No .env.example drift, no Slack messages saying "what was that variable name again?"

The mainstream alternative is sourcing .env files manually or using IDE-specific env loading. Both pollute the parent shell or only work inside one tool. direnv works for every command you run from that directory — make, psql, ad-hoc scripts, your editor's terminal — without any of them needing to know direnv exists.

Key Takeaway: direnv replaces manual source .env rituals with directory-scoped environments that load on cd in and unload on cd out — secure by default, transparent to every tool you run.

All newsletters