Per-Project Environments with direnv for Ops Work
Stop exporting AWS_PROFILE by hand and forgetting to unset it. direnv loads the right env vars when you cd in and unloads them when you leave.
- #bash
- #python
- #direnv
- #environment
- #tooling
- #workflow
Here’s a failure mode I’ve lived more than once: you export AWS_PROFILE=prod to run one command, get distracted, and three terminals later you run a “harmless” cleanup script that’s still pointed at production. The environment variable outlived its intended scope, and now you have an incident.
direnv fixes this whole class of problem. It loads environment variables automatically when you cd into a project directory and unloads them when you leave. Your prod credentials exist only inside the prod project folder. Your project’s virtualenv activates the moment you enter and deactivates when you go. It’s a small tool that quietly removes a lot of foot-shooting from day-to-day ops.
How it works
direnv hooks into your shell. When you change directories, it looks for a .envrc file in the current directory (and up the tree). If it finds one — and you’ve explicitly allowed it — it evaluates that file and exports whatever it defines into your shell. When you cd away, it reverses every change. The variables, the PATH additions, the activated venv: all scoped to that directory.
# Install (one of):
sudo apt install direnv # Debian/Ubuntu
brew install direnv # macOS
# Hook it into your shell (in ~/.bashrc):
eval "$(direnv hook bash)" # or zsh, fish, etc.
After hooking it, you restart your shell once and forget direnv exists until you need it.
A first .envrc
Drop a .envrc in a project directory:
# .envrc
export AWS_PROFILE=prod-readonly
export AWS_REGION=us-east-1
export DATABASE_URL="postgres://localhost/myapp_dev"
export LOG_LEVEL=debug
The first time direnv sees a new or changed .envrc, it refuses to load it and prints a warning. This is the security model and it matters:
direnv: error .envrc is blocked. Run `direnv allow` to approve its content
You explicitly approve it:
direnv allow
Now every time you enter that directory, those variables are set; every time you leave, they’re gone. cd ~/other-project and echo $AWS_PROFILE shows nothing. That automatic unloading is the whole safety win — your prod profile cannot leak into an unrelated shell session because it’s bound to the directory you’ve left.
Auto-activating Python environments
direnv’s most loved feature for Python work is layout python, which creates and activates a virtualenv automatically:
# .envrc
layout python python3.11
Now cd into the project and you’re inside its venv — no source .venv/bin/activate to remember, and no risk of running pip install into the wrong environment because you forgot to activate. Leave the directory and the venv deactivates itself.
If you use uv, you can point direnv at its venv directly:
# .envrc
source .venv/bin/activate
Either way, the activation lifecycle matches your presence in the directory. I’ve stopped having “oops, installed into system Python” moments entirely since adopting this.
Loading secrets without committing them
The pattern I rely on most: keep the structure in .envrc (committed) and the secrets in an ignored file. direnv has a built-in helper for exactly this:
# .envrc (committed to git)
dotenv_if_exists .env.local # loads KEY=value pairs if the file exists
export SERVICE_NAME=billing
# .env.local (gitignored — never committed)
API_TOKEN=sk-live-xxxxxxxx
DB_PASSWORD=hunter2
Add .env.local to .gitignore and .envrc to source it conditionally. Teammates clone the repo, drop in their own .env.local, run direnv allow, and they’re configured — without a single secret in version control.
For pulling secrets from a manager instead of a file, you can shell out in .envrc:
# .envrc
export DB_PASSWORD="$(aws secretsmanager get-secret-value \
--secret-id myapp/db --query SecretString --output text)"
Now the secret is fetched fresh on directory entry and never written to disk at all.
The habits that keep it safe
- Never commit real secrets to
.envrc. It’s committed by convention. Secrets go in a gitignored.env.localloaded viadotenv_if_exists. - Re-run
direnv allowdeliberately. The block-until-approved model exists because a.envrcruns arbitrary shell. If yougit pulland the.envrcchanged, read the diff before allowing it — a malicious or careless change could run anything. - Use
.envrcto make the safe profile the default. SetAWS_PROFILE=prod-readonly, not the admin profile. The principle from the opening — scope dangerous credentials tightly — applies here: default to least privilege per directory. - Check
direnv statusif a variable isn’t loading; it tells you which.envrcfiles are loaded and whether they’re allowed.
Why it’s worth the five-minute setup
The value isn’t convenience, though the auto-activating venvs are genuinely nice. The value is scoping. Environment variables are global by default, which is precisely why they cause incidents — a credential set for one task lingers and gets used by another. direnv makes them local to the directory that needs them, so the prod profile, the debug log level, and the staging database URL all stay exactly where they belong and vanish when you walk away.
For more workflow tooling that makes automation safer, browse the Bash & Python automation guides or start from a prompt.
.envrc files execute arbitrary shell on directory entry. Only allow files you’ve read, and never approve a changed .envrc from an untrusted source without reviewing the diff.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.