Skip to content

direnv: Per-Project Environment Variables

Every developer eventually types export AWS_PROFILE=prod into a terminal tab, does the thing they meant to do, and moves on. The variable does not move on. It sits in that tab, patiently, until three hours later when the same tab is used for a quick terraform destroy that was intended for the sandbox account. Nobody has ever been fired by a shell variable, strictly speaking. They have been fired near one.

direnv makes the environment a property of the directory instead of the tab. Put an .envrc in a project; cd in and its variables are loaded, cd out and they are gone — including any values they had temporarily replaced, which come back exactly as they were:

Terminal window
~/projects$ cd shop
direnv: error /Users/you/projects/shop/.envrc is blocked. Run `direnv allow` to approve its content
~/projects/shop$ direnv allow
direnv: loading ~/projects/shop/.envrc
direnv: export +AWS_PROFILE +DATABASE_URL ~PATH
~/projects/shop$ echo $AWS_PROFILE
shop-dev
~/projects/shop$ cd ..
direnv: unloading
~/projects$ echo $AWS_PROFILE

That is the whole idea. The rest of this guide is installing it, the rules it follows, the standard library that makes .envrc files short, and the half-dozen places where it behaves in a way you would not have guessed.

You want Write this in .envrc
A variable for this project only export DATABASE_URL=postgres://localhost/shop_dev
The project’s bin/ on your PATH PATH_add bin
Secrets from an untracked .env file dotenv_if_exists
A Python virtualenv, created and activated layout python3
The parent directory’s .envrc as well source_up
Personal overrides nobody else gets source_env_if_exists .envrc.local
A loud failure instead of a half-loaded one strict_env

And the commands you will actually type: direnv allow after creating or editing an .envrc, direnv deny to switch one off, direnv reload to force a reload, direnv status when it is misbehaving.

Terminal window
brew install direnv # macOS
sudo apt install direnv # Debian, Ubuntu
sudo dnf install direnv # Fedora

Installing it does nothing on its own. direnv works by hooking your shell’s prompt: before each prompt it checks whether the directory or its .envrc has changed, and if so, works out the difference and applies it. Add the hook to your shell’s startup file and open a new shell:

~/.zshrc
eval "$(direnv hook zsh)"

That check runs at every prompt, so it has to be cheap, and it is: in a directory with nothing to load it costs a few milliseconds. It also means an edited .envrc is noticed at the next prompt, without leaving and coming back. If you have put your shell on a startup diet, this one line won’t undo it.

~/projects/shop/.envrc
export DATABASE_URL=postgres://localhost:5432/shop_dev
export AWS_PROFILE=shop-dev
PATH_add bin

cd into the directory and direnv refuses, in red, to have anything to do with it until you run direnv allow — see the transcript at the top. After that it loads, and prints a one-line diff of what changed:

  • +NAME — a variable that didn’t exist and now does.
  • ~NAME — one that already existed and now has a different value. PATH turns up here constantly.
  • -NAME — one the .envrc removed with unset.

Leaving the directory prints direnv: unloading and reverses the lot. A ~ variable gets its previous value back, not an empty one, so a shell where AWS_PROFILE was personal before you visited the project is still personal afterwards.

PATH_add bin is the first piece of the standard library, and it already earns its keep: it resolves bin relative to the .envrc rather than to wherever you happen to be standing, so the project’s scripts stay on your PATH from any subdirectory.

An .envrc is a bash script, and direnv runs it automatically when you cd. Without a gate, cloning a stranger’s repository and looking inside it would be enough to run their code. So direnv keeps a list of .envrc files you have approved, keyed by path and contents, and runs nothing that isn’t on it.

The contents part is the one that catches people out. Edit an allowed .envrc — add one line, fix a typo — and at the next prompt it is blocked again:

direnv: error /Users/you/projects/shop/.envrc is blocked. Run `direnv allow` to approve its content

Being blocked doesn’t leave the old values in place, either. direnv unloads everything that .envrc had set, so the edit you were halfway through removes the variables you were relying on until you re-allow it. The same happens when a git pull brings in somebody else’s change to the file, which is the point: you see the new version before it runs. Look at the diff, then type direnv allow.

The .envrc usually belongs in the repository. It documents what the project expects, and a new starter gets a working environment by typing direnv allow. Secrets do not belong in it, because they do not belong in the repository. Put those in a .env that git ignores, and have the .envrc load it if it’s there:

Terminal window
# .envrc — committed
export DATABASE_URL=postgres://localhost:5432/shop_dev
export AWS_PROFILE=shop-dev
dotenv_if_exists
Terminal window
# .env — never committed
STRIPE_SECRET_KEY=sk_test_51...
GITHUB_TOKEN=ghp_...
.gitignore
.env
.envrc.local
.direnv/

Three things are worth knowing about how direnv reads .env:

  • It parses the file; it does not run it. Plain KEY=value lines, quotes, comments, export prefixes and ${OTHER_KEY} references all work. $(...) and backticks don’t: a line like TOKEN=$(op read ...) is rejected as invalid line, and so is the rest of the file with it. If you need a command’s output, that line belongs in the .envrc.
  • It is watched. Edit the .env and the next prompt reloads it, with no direnv allow needed.
  • dotenv and dotenv_if_exists differ only in whether a missing file is an error. Use the second for anything a fresh clone won’t have.

If you would rather the secret never touched the disk at all, the .envrc can fetch it from a password manager’s CLI at load time. That runs on every load, which is fine for a fast local lookup and tiresome for anything that prompts for a fingerprint each time you cd.

The principle is the one the dotfiles guide applies to your home directory: track the file, source an untracked sibling.

Subdirectories and source_up: the nearest-file rule

Section titled “Subdirectories and source_up: the nearest-file rule”

direnv loads one .envrc: the nearest, walking up from the current directory. In a subdirectory with no .envrc of its own, the parent’s applies. But the moment a subdirectory gets one, the parent’s is not loaded as well. It is replaced:

~/projects/shop/api/.envrc
export PORT=8081
direnv: loading ~/projects/shop/api/.envrc
direnv: export +PORT

DATABASE_URL and AWS_PROFILE have gone, which is rarely what anyone writing a three-line .envrc for a subdirectory intended. source_up loads the parent’s file first:

~/projects/shop/api/.envrc
source_up
export PORT=8081
direnv: loading ~/projects/shop/api/.envrc
direnv: loading ~/projects/shop/.envrc
direnv: export +AWS_PROFILE +DATABASE_URL +PORT

Monorepos are the natural home for this: shared settings at the top, a source_up and a couple of overrides in each service.

direnv stdlib prints all of it, and the stdlib manual explains it. These are the parts that come up:

layout python3 creates a virtualenv in .direnv/python-3.x on first load and activates it from then on. No source venv/bin/activate, no forgetting to, and no deactivate — leaving the directory does that. layout node puts node_modules/.bin on the PATH, and there are equivalents for Go, Ruby, Perl and PHP. The .direnv/ directory is direnv’s own cache, and goes in .gitignore.

source_env_if_exists .envrc.local gives each developer a personal override file:

Terminal window
# .envrc — committed
export LOG_LEVEL=info
source_env_if_exists .envrc.local
Terminal window
# .envrc.local — yours alone
export LOG_LEVEL=debug

Put the line last, so the personal file has the final word. Unlike .env, this file is a real bash script, with all that implies (see the caution above).

This checks that variables a project can’t work without are set and non-empty:

Terminal window
dotenv_if_exists
env_vars_required STRIPE_SECRET_KEY GITHUB_TOKEN
direnv: env var GITHUB_TOKEN is required but missing/empty

It complains and carries on loading the rest. If you want a missing secret to stop everything, you need the next one.

This turns on set -euo pipefail for the rest of the file, with exactly the semantics in our bash strict mode guide. Without it, an .envrc behaves like any other bash script, which is to say it carries on regardless. A command that fails, or a syntax error halfway down, still leaves you with every variable from the lines before it, a half-configured environment, and a direnv: export line that looks entirely normal. With it, the load fails as a whole (direnv: error exit status 1) and nothing is applied. Put it on the first line, or set it for every .envrc you ever load in the config file below; the manual says it will become the default in a future release anyway.

This reloads the environment when some other file changes. Useful when the .envrc reads a version from .tool-versions or a lockfile and you want the environment to follow it.

direnv runs your .envrc in a separate bash process, compares the environment before and after, and applies the difference to your shell. Anything that isn’t an environment variable doesn’t survive the trip:

  • Aliases and functions. alias k=kubectl in an .envrc runs, and then vanishes with the process that ran it. Put a wrapper script in bin/ and PATH_add bin instead; it works in every shell, which an alias never did.
  • cd. It changes the subprocess’s directory, not yours.
  • Shell options and the prompt. Same reason. PS1 and friends are best left to the prompt you already configured.

A variable you set by hand gets reverted. Inside a project whose .envrc sets AWS_PROFILE=shop-dev, you run export AWS_PROFILE=prod for one command. On the way out, direnv restores the value from before you arrived, quietly discarding yours. Variables the .envrc doesn’t mention are left alone. If you need a different value for a while, put it in .envrc.local.

direnv: unloading in a brand new tmux pane. tmux keeps a copy of the environment it was started in, and a server started from inside a project hands that project’s variables to every new pane. With the hook installed, the pane’s shell notices it isn’t in that directory and cleans up, hence the message. Anything tmux starts without a shell (a run-shell, a pane running a command directly) gets the stale values and keeps them. The environment tmux keeps is the one from the first tmux new that started the server, so run that from your home directory rather than from inside a project.

Your editor sees none of it. Variables are loaded by the shell’s prompt hook. A GUI editor launched from the Dock never ran that hook, so its terminal does and its test runner doesn’t. Most editors have a direnv extension; for everything else, direnv exec loads a directory’s environment for a single command:

Terminal window
direnv exec ~/projects/shop ./bin/nightly-report

That is also the answer for cron jobs and CI scripts that need the same environment you do.

A .env on its own is ignored. direnv only reads .env when an .envrc asks it to, unless you set load_dotenv in the config file. Even then a lone .env has to be allowed like an .envrc.

The diff line is noisy in a big project. Twenty variables make for a long export line on every cd. The config file below can hide it.

direnv reads ~/.config/direnv/direnv.toml. Most people need two or three lines of it:

~/.config/direnv/direnv.toml
[global]
hide_env_diff = true # keep "loading", drop the +VAR ~VAR line
strict_env = true # every .envrc runs under set -euo pipefail
load_dotenv = true # a .env with no .envrc is enough

If you want it quieter still, log_filter takes a regular expression and shows only the messages that match it, so log_filter = "^$" hides all the status lines while errors still get through. The config also has log_format = "-", documented as switching normal logging off; in 2.37 it prints Go format-string debris instead, so use log_filter.

There is also a [whitelist] section that skips the approval for every .envrc under a given directory:

[whitelist]
prefix = [ "~/work" ]

It works, and it means any .envrc that arrives under ~/work, by git clone, git pull or a colleague’s branch, runs on cd without being seen first. The manual’s own word for it is “great care”. Use exact with a list of specific files, if you must use it at all.

Starship has a direnv module, switched off by default. Two lines turn it on:

~/.config/starship.toml
[direnv]
disabled = false

It reads direnv loaded/allowed in a project that loaded, and direnv not loaded/not allowed in one whose .envrc is waiting for you, which is the state worth noticing before you run anything. Starship’s aws and python modules already show the profile and virtualenv the .envrc set, so between them you can see which account a command is about to run against, which is roughly where this article started.

  • Per-directory git settings — a work email, a signing key — are better done by git itself, with includeIf, which works from scripts, editors and every other tool, not just a shell that ran a hook.
  • Per-host settings for SSH belong in the SSH config, not in a variable that only exists while you are standing in the right directory.
  • Tool versions. If what you mostly want is “this project uses Node 22”, a version manager such as mise or asdf is the tool for it, and mise will set environment variables from its own config as well. direnv happily sits on top of either, through use and layout, if you want to keep the environment in an .envrc.
  • Nix users already know about use flake. Everyone else can ignore it, and should not be alarmed by the nix: command not found it produces if they don’t.

The .envrc files belong in each project’s repository; the direnv.toml and the hook line belong in your dotfiles. The zsh guide has a home for that hook line, the git config guide handles the per-directory settings git can do for itself, and if the .envrc is starting to look like a program, bash strict mode covers the language it is written in. strict_env is that guide in two words.

For everything this guide skipped, direnv’s own documentation has the reference pages, and the GitHub repository has the changelog for when 2.37 stops being current.