`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

  1. What direnv is
  2. Why macOS + zsh users like it
  3. How direnv works
  4. Install on macOS
  5. Hook direnv into zsh correctly
  6. Your first working example
  7. Understanding .envrc, .env, and authorization
  8. Common .envrc patterns
  9. A production-friendly project layout
  10. Using direnv with Node.js, Python, and polyglot repos
  11. Useful commands
  12. Configuration with direnv.toml
  13. Security model and safe habits
  14. Troubleshooting on macOS + zsh
  15. Recipes
  16. Cheat sheet
  17. 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:


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:

  1. direnv is not simply sourcing .envrc directly into your live zsh session.2
  2. It can therefore work across multiple shells, including zsh, bash, fish, tcsh, and others.14
  3. 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

brew install direnv

Verify installation

direnv version
which direnv

Why Homebrew is the simplest macOS path

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:

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


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

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.

  1. Read .envrc before allowing it.
  2. Commit only non-secret shared logic when possible.
  3. Keep secrets in ignored local files such as .env, .envrc.private, or a separate secrets manager workflow.
  4. Be careful with source_env and source_up because the stdlib notes that sourced .envrc files are not checked by the security framework in the same way.7
  5. 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:

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

  1. direnv home page and quick demo: direnv.net2
  2. Installation docs: [Installation direnv](https://direnv.net/docs/installation.html)5
  3. Shell hook docs: [Setup direnv](https://direnv.net/docs/hook.html)4
  4. direnv(1) manual: direnv man page1
  5. direnv-stdlib(1) manual: direnv stdlib man page7
  6. direnv.toml(1) manual: direnv.toml man page8

macOS package source

  1. Homebrew formula page: direnv — Homebrew Formulae6

zsh documentation

  1. zsh startup-file overview: An Introduction to the Z Shell — Startup Files3

Footnotes

  1. Official direnv(1) manual describing how direnv loads authorized .envrc files before each prompt, the Bash subprocess model, zsh setup, and commands such as allow, deny, exec, export, and reload: direnv man page 2 3 4 5 6 7 8 9 10 11 12 13

  2. direnv home page, including overview, quick demo, upward .envrc lookup, .env notes, stdlib notes, and FAQ: direnv.net 2 3 4 5 6 7 8 9 10 11 12

  3. zsh startup-file documentation describing the roles of ~/.zshenv, ~/.zprofile, ~/.zshrc, and ~/.zlogin: An Introduction to the Z Shell — Startup Files 2 3 4

  4. Official shell hook docs, including the zsh line eval "$(direnv hook zsh)" and Oh My Zsh plugin note: [Setup direnv](https://direnv.net/docs/hook.html).

     2 3 4

  5. Official installation docs listing macOS Homebrew and noting installation plus shell hook setup: [Installation direnv](https://direnv.net/docs/installation.html).

     2 3

  6. Homebrew formula page for direnv, which currently shows brew install direnv and stable version 2.37.1: direnv — Homebrew Formulae 2 3

  7. Official direnv-stdlib(1) manual covering helpers such as dotenv_if_exists, PATH_add, source_env_if_exists, env_vars_required, layout node, layout python, watch_file, and watch_dir: direnv stdlib man page 2 3 4 5 6 7 8 9 10 11 12 13 14 15

  8. 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