`direnv` on macOS with zsh: a comprehensive guide
A practical guide for macOS users who want project-specific environment variables to load automatically when entering a directory and disappear when leaving it.
Table of contents
- What
direnvis - Why macOS + zsh users like it
- How
direnvworks - Install on macOS
- Hook
direnvinto zsh correctly - Your first working example
- Understanding
.envrc,.env, and authorization - Common
.envrcpatterns - A production-friendly project layout
- Using
direnvwith Node.js, Python, and polyglot repos - Useful commands
- Configuration with
direnv.toml - Security model and safe habits
- Troubleshooting on macOS + zsh
- Recipes
- Cheat sheet
- References
What direnv is
direnv is an environment-variable manager that integrates with your shell so variables can be loaded and unloaded based on the current directory.12 In practice, that means you can cd into a project and have its environment appear automatically, then cd out and have it disappear.
For macOS users working in Terminal, iTerm2, Warp, VS Code integrated terminals, or tmux sessions running zsh, this solves a very common problem: you want per-project configuration, but you do not want to permanently export project variables in your global shell startup files.
Why macOS + zsh users like it
On macOS, zsh is a natural fit because interactive shell customizations belong in ~/.zshrc, while login-shell-only setup belongs elsewhere.3 Since direnv works by hooking into the interactive shell prompt, zsh users can keep the integration in the right place and avoid cluttering ~/.zprofile or ~/.zshenv.
direnv is especially useful on a Mac when you:
- switch between multiple AWS profiles or cloud accounts
- work across several repos with different secrets and endpoints
- want
PATHadditions to apply only inside one project - want local development variables to disappear when you leave the repo
- use Node.js, Python, Ruby, or mixed-language repos and do not want one project’s setup leaking into another
How direnv works
According to the official docs and manual, before each prompt direnv checks the current directory and its parents for an authorized .envrc file, loads it in a Bash subprocess, captures the exported environment differences, and then applies only the resulting environment changes back to your current shell.21
That design matters because it explains three behaviors that surprise new users:
direnvis not simply sourcing.envrcdirectly into your live zsh session.2- It can therefore work across multiple shells, including zsh, bash, fish, tcsh, and others.14
- Shell aliases and functions are not the main thing it exports; the focus is environment changes.2
Mental model
flowchart TD
A[You run cd into a project] --> B[zsh prompt redraws]
B --> C[direnv hook runs]
C --> D{Find authorized .envrc in current dir or parents?}
D -- No --> E[No project env loaded]
D -- Yes --> F[Load .envrc in Bash subprocess]
F --> G[Compute env diff]
G --> H[Apply exports and unsets to current zsh shell]
H --> I[Prompt appears with project env active]
I --> J[You cd out]
J --> K[direnv hook runs again]
K --> L[Unload variables that no longer apply]
Search behavior
direnv looks upward through parent directories, not just the current directory.21 That lets you place one .envrc at a repo root and have it apply in subdirectories.
flowchart LR
A["/Users/you/code/app/api"] --> B["/Users/you/code/app"]
B --> C["/Users/you/code"]
C --> D["/Users/you"]
B -. "contains .envrc" .-> E[direnv uses repo-root config]
Install on macOS
The official installation docs list Homebrew as the macOS package route, and the Homebrew formula page currently shows brew install direnv with a stable version of 2.37.1.56
Recommended installation
brew install direnv
Verify installation
direnv version
which direnv
Why Homebrew is the simplest macOS path
- it is the package method explicitly listed in the official
direnvinstallation docs for macOS5 - the formula page documents current availability and versioning on supported macOS platforms6
- upgrades are straightforward:
brew upgrade direnv
Installation flow
flowchart TD
A[Install Homebrew if needed] --> B[brew install direnv]
B --> C[Confirm direnv version]
C --> D[Add zsh hook]
D --> E[Restart shell or source ~/.zshrc]
E --> F[Create .envrc in project]
F --> G[Run direnv allow]
Hook direnv into zsh correctly
The official setup docs say to add this line to the end of ~/.zshrc:41
eval "$(direnv hook zsh)"
Why ~/.zshrc and not ~/.zprofile?
The zsh startup documentation says ~/.zshrc is read for interactive shells, while ~/.zprofile is for login-shell-only actions.3 Since direnv integrates with your prompt behavior during interactive use, ~/.zshrc is the correct place.
Steps
Open your zsh config:
nano ~/.zshrc
Add this at the end:
eval "$(direnv hook zsh)"
Reload your shell:
source ~/.zshrc
Or close and reopen Terminal.
If you use Oh My Zsh
The official setup page notes that Oh My Zsh has a core direnv plugin; you can add direnv to your plugins=(...) array.4 For many users, though, the explicit eval "$(direnv hook zsh)" line is easier to reason about.
zsh startup placement
flowchart TD
A[~/.zshenv] -->|all invocations| X[Too early / too global for direnv hook]
B[~/.zprofile] -->|login shells| Y[Not ideal for interactive prompt hook]
C[~/.zshrc] -->|interactive shells| Z[Best place for direnv hook]
D[~/.zlogin] -->|after .zshrc in login shells| W[Not the usual place for env tooling]
Your first working example
Create a demo project:
mkdir -p ~/code/direnv-demo
cd ~/code/direnv-demo
Create .envrc:
echo 'export HELLO_FROM_DIRENV="loaded"' > .envrc
At first, direnv should refuse to load it until you authorize it. The official quick demo shows this allow/deny security step explicitly.21
Authorize the file:
direnv allow
Check the variable:
echo "$HELLO_FROM_DIRENV"
Leave the directory:
cd ..
echo "$HELLO_FROM_DIRENV"
You should see it disappear when you leave.
First-run lifecycle
sequenceDiagram
participant U as You
participant Z as zsh
participant D as direnv
participant E as .envrc
U->>Z: cd ~/code/direnv-demo
Z->>D: prompt hook runs
D->>E: sees .envrc
D-->>Z: blocked until authorized
U->>D: direnv allow
D->>E: evaluate file
D-->>Z: export +HELLO_FROM_DIRENV
U->>Z: cd ..
Z->>D: prompt hook runs again
D-->>Z: unload variable
Understanding .envrc, .env, and authorization
.envrc
.envrc is the primary project file direnv evaluates.21 It is Bash code, which means you can do more than set fixed key-value pairs.
Example:
export APP_ENV=dev
export AWS_PROFILE=my-dev-profile
PATH_add bin
.env
The docs also describe .env support, but with an important distinction: .envrc can use the direnv stdlib, while .env is only simple variable data and does not support those helper functions.278
Useful patterns include:
- keep logic in
.envrc - keep plain secrets or local overrides in
.envor another ignored file - use
dotenvordotenv_if_existsfrom.envrcwhen you want both27
Example:
# .envrc
export APP_ENV=dev
dotenv_if_exists .env
Authorization model
A new or changed .envrc is not trusted automatically. You must approve it with direnv allow, and you can revoke trust with direnv deny.1
That security model is the reason direnv is safer than blindly sourcing every project’s shell file.
File-role diagram
flowchart LR
A[.envrc] -->|logic, exports, stdlib helpers| D[direnv evaluation]
B[.env] -->|plain key=value data| D
C[~/.config/direnv/direnvrc] -->|user-wide extensions/helpers| D
D --> E[Environment diff applied to zsh]
Common .envrc patterns
The official stdlib includes helpers such as dotenv, dotenv_if_exists, PATH_add, source_env, source_env_if_exists, env_vars_required, layout python, layout node, watch_file, and watch_dir.7
1. Simple exports
# .envrc
export APP_ENV=dev
export AWS_PROFILE=myapp-dev
export AWS_REGION=ap-northeast-2
2. Load a .env file only if it exists
# .envrc
export APP_ENV=dev
dotenv_if_exists .env
3. Add project-local binaries to PATH
Using PATH_add is preferred to hand-editing PATH because the stdlib is designed to prepend safely.27
# .envrc
PATH_add bin
PATH_add scripts
4. Require variables from a private file
# .envrc
source_env_if_exists .envrc.private
env_vars_required AWS_PROFILE AWS_REGION
This pattern is useful when a committed .envrc defines the shared structure, but each developer keeps secrets or machine-specific values in a private ignored file.7
5. Reload when a file changes
# .envrc
watch_file package.json
watch_file .nvmrc
The stdlib says watch_file adds files to the watch list so direnv reloads the environment on the next prompt if they change.7
6. Reload when an entire directory tree changes
# .envrc
watch_dir config
This is useful when you derive environment setup from generated or config files.7
A production-friendly project layout
A nice macOS team setup is to keep a safe, readable .envrc in Git while putting secrets or machine-specific values in ignored files.
my-project/
├─ .envrc
├─ .envrc.private # gitignored
├─ .env # optional, gitignored
├─ bin/
├─ package.json
└─ src/
Example .gitignore
.env
.envrc.private
.direnv/
Example committed .envrc
export APP_NAME=my-project
export APP_ENV=dev
PATH_add bin
source_env_if_exists .envrc.private
dotenv_if_exists .env
env_vars_required APP_ENV
Why this layout works
- shared defaults live in version control
- secrets stay local
direnvhelpers keep the file expressive- teammates can understand the environment structure without seeing your private values
Using direnv with Node.js, Python, and polyglot repos
The official stdlib includes layout helpers for several ecosystems, including layout node and layout python.7
Node.js project
layout node adds node_modules/.bin to PATH.7
# .envrc
layout node
dotenv_if_exists .env
watch_file package.json package-lock.json .nvmrc
Good fit when you want tools like eslint, tsx, vite, or jest available automatically inside the project.
Python project
layout python creates and loads a virtual environment under .direnv/ scoped to the project.7
# .envrc
layout python python3
dotenv_if_exists .env
watch_file requirements.txt pyproject.toml
Mixed repo example
# .envrc
export APP_ENV=dev
PATH_add bin
layout node
source_env_if_exists .envrc.private
dotenv_if_exists .env
watch_file package.json .env .envrc.private
Ecosystem view
flowchart TD
A[Repo root .envrc] --> B[Shared exports]
A --> C[PATH_add bin]
A --> D[dotenv_if_exists .env]
A --> E[source_env_if_exists .envrc.private]
A --> F[layout node or layout python]
F --> G[Project-local tools on PATH]
D --> H[Local variables loaded]
E --> I[Private secrets loaded]
Useful commands
The direnv manual documents the main commands below.1
Allow a file
direnv allow
Trust the current .envrc or .env after creating or modifying it.
Revoke trust
direnv deny
Useful if you want to block the current environment until you review it.
Reload explicitly
direnv reload
Helpful after editing environment-related files when you want immediate feedback.
Run one command inside another directory’s environment
direnv exec /path/to/project env | rg '^AWS_'
The manual describes direnv exec DIR COMMAND as executing a command after loading the first .envrc or .env found in that directory.1
Inspect export output
direnv export zsh
The manual says direnv export SHELL prints the environment diff in a form suitable for a shell or other supported output targets.1
Configuration with direnv.toml
The direnv.toml manual says the config file lives at $XDG_CONFIG_HOME/direnv/direnv.toml, which is typically ~/.config/direnv/direnv.toml on macOS.8
Example config file
[global]
strict_env = true
hide_env_diff = false
warn_timeout = "5s"
Why these settings matter
strict_env = trueloads.envrcwithset -euo pipefail.8hide_env_diff = truecan reduce noise if you dislike variable diff logs.8warn_timeoutcontrols whendirenvwarns that evaluation is taking too long; the default is5s.8
About automatic .env loading
The config option load_dotenv = true tells direnv to look for .env files in addition to .envrc; if both exist, .envrc is chosen first.8
[global]
load_dotenv = true
About whitelist settings
direnv.toml also supports whitelist rules, but the official docs warn that trusted path prefixes and exact-path auto-allow rules should be used with great care because anyone who can write there may be able to execute arbitrary code on your machine.8
That is powerful for internal monorepos or tightly controlled local directories, but it should be an intentional decision.
Security model and safe habits
direnv’s security story is one of its best features. The official docs require explicit authorization of .envrc changes with direnv allow, and the configuration docs warn carefully about auto-trusting directories.218
The big rule
Treat .envrc as executable code, not as harmless text.
Recommended habits
- Read
.envrcbefore allowing it. - Commit only non-secret shared logic when possible.
- Keep secrets in ignored local files such as
.env,.envrc.private, or a separate secrets manager workflow. - Be careful with
source_envandsource_upbecause the stdlib notes that sourced.envrcfiles are not checked by the security framework in the same way.7 - Be cautious with whitelist rules in
direnv.toml.8
Security decision tree
flowchart TD
A[New or changed .envrc detected] --> B{Did you read and trust it?}
B -- No --> C[Do not run direnv allow]
B -- Yes --> D[Run direnv allow]
D --> E{Does it source other local files?}
E -- Yes --> F[Review those too]
E -- No --> G[Proceed]
F --> H{Is there a whitelist rule involved?}
G --> H
H -- Yes --> I[Confirm directory is tightly controlled]
H -- No --> J[Use normal allow flow]
Troubleshooting on macOS + zsh
direnv is installed but does nothing
Check that the hook is present in ~/.zshrc:
rg 'direnv hook zsh' ~/.zshrc
Then reload zsh:
source ~/.zshrc
If needed, confirm your shell:
echo "$SHELL"
echo "$0"
I added .envrc but variables do not load
Likely causes:
- you forgot
direnv allow .envrchas a syntax error- the hook is not loaded in the current terminal session
- the variables are defined in a child process rather than exported
Debug steps:
direnv status
direnv reload
direnv export zsh
It works in one terminal app but not another
That usually means one app is starting an interactive zsh that reads ~/.zshrc, and another is using a different shell mode or startup sequence. The zsh docs are a good reminder that startup files have distinct roles.3
My prompt shows noisy environment diffs
You can configure hide_env_diff in ~/.config/direnv/direnv.toml.8
[global]
hide_env_diff = true
I changed a dependency file but direnv did not react
Use watch_file or watch_dir in .envrc for files that should trigger a reload on the next prompt.7
My shell breaks because a variable is missing
If you want stricter behavior, set strict_env = true in direnv.toml or use env_vars_required in .envrc.87
Recipes
Recipe 1: AWS project on macOS
# .envrc
export AWS_PROFILE=myapp-dev
export AWS_REGION=ap-northeast-2
export APP_ENV=dev
env_vars_required AWS_PROFILE AWS_REGION APP_ENV
Recipe 2: Shared repo config + local secrets
# .envrc
export APP_NAME=file-service
export APP_ENV=dev
PATH_add bin
source_env_if_exists .envrc.private
# .envrc.private
export AWS_PROFILE=team-dev
export JWT_SHARED_SECRET=local-only-secret
Recipe 3: Node.js service
# .envrc
layout node
export NODE_ENV=development
dotenv_if_exists .env
watch_file package.json package-lock.json .env
Recipe 4: Python service
# .envrc
layout python python3
export FLASK_ENV=development
dotenv_if_exists .env
watch_file pyproject.toml requirements.txt .env
Recipe 5: Monorepo root config
Repo root:
# .envrc
export ORG_NAME=acme
PATH_add bin
Subproject:
# apps/api/.envrc
source_up
export SERVICE_NAME=api
source_env_if_exists .envrc.private
This pattern uses the root environment and extends it per subproject. Be careful and review all sourced files because stdlib notes that these helpers bypass the normal per-file security check behavior for nested sourced env files.7
Cheat sheet
Install
brew install direnv
zsh hook
Add to ~/.zshrc:
eval "$(direnv hook zsh)"
Create and trust a project env
cd my-project
$EDITOR .envrc
direnv allow
Common helpers
PATH_add bin
dotenv_if_exists .env
source_env_if_exists .envrc.private
env_vars_required AWS_PROFILE AWS_REGION
watch_file package.json .env
layout node
layout python python3
Useful commands
direnv allow
direnv deny
direnv reload
direnv status
direnv exec /path/to/project env
direnv export zsh
References
Official direnv docs
direnvhome page and quick demo: direnv.net2-
Installation docs: [Installation direnv](https://direnv.net/docs/installation.html)5 -
Shell hook docs: [Setup direnv](https://direnv.net/docs/hook.html)4 direnv(1)manual: direnv man page1direnv-stdlib(1)manual: direnv stdlib man page7direnv.toml(1)manual: direnv.toml man page8
macOS package source
- Homebrew formula page: direnv — Homebrew Formulae6
zsh documentation
- zsh startup-file overview: An Introduction to the Z Shell — Startup Files3
Footnotes
-
Official
direnv(1)manual describing howdirenvloads authorized.envrcfiles before each prompt, the Bash subprocess model, zsh setup, and commands such asallow,deny,exec,export, andreload: direnv man page. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 -
direnvhome page, including overview, quick demo, upward.envrclookup,.envnotes, stdlib notes, and FAQ: direnv.net. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 -
zsh startup-file documentation describing the roles of
~/.zshenv,~/.zprofile,~/.zshrc, and~/.zlogin: An Introduction to the Z Shell — Startup Files. ↩ ↩2 ↩3 ↩4 -
Official shell hook docs, including the zsh line eval "$(direnv hook zsh)"and Oh My Zsh plugin note: [Setupdirenv](https://direnv.net/docs/hook.html). -
Official installation docs listing macOS Homebrew and noting installation plus shell hook setup: [Installation direnv](https://direnv.net/docs/installation.html). -
Homebrew formula page for
direnv, which currently showsbrew install direnvand stable version2.37.1: direnv — Homebrew Formulae. ↩ ↩2 ↩3 -
Official
direnv-stdlib(1)manual covering helpers such asdotenv_if_exists,PATH_add,source_env_if_exists,env_vars_required,layout node,layout python,watch_file, andwatch_dir: direnv stdlib man page. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 -
Official
direnv.toml(1)manual covering config file location,load_dotenv,strict_env,warn_timeout,hide_env_diff, and whitelist behavior: direnv.toml man page. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12