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:
~/projects$ cd shopdirenv: error /Users/you/projects/shop/.envrc is blocked. Run `direnv allow` to approve its content~/projects/shop$ direnv allowdirenv: loading ~/projects/shop/.envrcdirenv: export +AWS_PROFILE +DATABASE_URL ~PATH~/projects/shop$ echo $AWS_PROFILEshop-dev~/projects/shop$ cd ..direnv: unloading~/projects$ echo $AWS_PROFILEThat 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.
The short version
Section titled “The short version”| 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.
Install it and hook it in
Section titled “Install it and hook it in”brew install direnv # macOSsudo apt install direnv # Debian, Ubuntusudo dnf install direnv # FedoraInstalling 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:
eval "$(direnv hook zsh)"# ~/.bashrc — at the very endeval "$(direnv hook bash)"The bash hook works through PROMPT_COMMAND, which other tools like to rearrange. Put it after
anything else that touches the prompt, Starship included.
direnv hook fish | sourceThat 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.
Your first .envrc
Section titled “Your first .envrc”export DATABASE_URL=postgres://localhost:5432/shop_devexport AWS_PROFILE=shop-devPATH_add bincd 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.PATHturns up here constantly.-NAME— one the.envrcremoved withunset.
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.
Why it is blocked until you direnv allow
Section titled “Why it is blocked until you direnv allow”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 contentBeing 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.
Secrets: commit the .envrc, not the .env
Section titled “Secrets: commit the .envrc, not the .env”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:
# .envrc — committedexport DATABASE_URL=postgres://localhost:5432/shop_devexport AWS_PROFILE=shop-devdotenv_if_exists# .env — never committedSTRIPE_SECRET_KEY=sk_test_51...GITHUB_TOKEN=ghp_....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=valuelines, quotes, comments,exportprefixes and${OTHER_KEY}references all work.$(...)and backticks don’t: a line likeTOKEN=$(op read ...)is rejected asinvalid 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
.envand the next prompt reloads it, with nodirenv allowneeded. dotenvanddotenv_if_existsdiffer 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:
export PORT=8081direnv: loading ~/projects/shop/api/.envrcdirenv: export +PORTDATABASE_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:
source_upexport PORT=8081direnv: loading ~/projects/shop/api/.envrcdirenv: loading ~/projects/shop/.envrcdirenv: export +AWS_PROFILE +DATABASE_URL +PORTMonorepos are the natural home for this: shared settings at the top, a source_up and a couple
of overrides in each service.
The standard library worth knowing
Section titled “The standard library worth knowing”direnv stdlib prints all of it, and the stdlib manual
explains it. These are the parts that come up:
layout: Python virtualenvs and more
Section titled “layout: Python virtualenvs and more”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
Section titled “source_env_if_exists”source_env_if_exists .envrc.local gives each developer a personal override file:
# .envrc — committedexport LOG_LEVEL=infosource_env_if_exists .envrc.local# .envrc.local — yours aloneexport LOG_LEVEL=debugPut 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).
env_vars_required
Section titled “env_vars_required”This checks that variables a project can’t work without are set and non-empty:
dotenv_if_existsenv_vars_required STRIPE_SECRET_KEY GITHUB_TOKENdirenv: env var GITHUB_TOKEN is required but missing/emptyIt complains and carries on loading the rest. If you want a missing secret to stop everything, you need the next one.
strict_env
Section titled “strict_env”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.
watch_file
Section titled “watch_file”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.
What an .envrc can’t do
Section titled “What an .envrc can’t do”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=kubectlin an.envrcruns, and then vanishes with the process that ran it. Put a wrapper script inbin/andPATH_add bininstead; 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.
PS1and friends are best left to the prompt you already configured.
Common direnv gotchas
Section titled “Common direnv gotchas”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:
direnv exec ~/projects/shop ./bin/nightly-reportThat 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.
The config file: direnv.toml
Section titled “The config file: direnv.toml”direnv reads ~/.config/direnv/direnv.toml. Most people need two or three lines of it:
[global]hide_env_diff = true # keep "loading", drop the +VAR ~VAR linestrict_env = true # every .envrc runs under set -euo pipefailload_dotenv = true # a .env with no .envrc is enoughIf 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.
Show it in the prompt
Section titled “Show it in the prompt”Starship has a direnv module, switched off by
default. Two lines turn it on:
[direnv]disabled = falseIt 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.
When direnv isn’t the answer
Section titled “When direnv isn’t the answer”- 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
useandlayout, 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 thenix: command not foundit produces if they don’t.
Where next
Section titled “Where next”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.