A colleague once ran terraform apply against production instead of staging.
The configuration was valid. The workspace name was not mistyped. The shell was simply carrying AWS_PROFILE=prod from work done hours earlier, and nothing in the new project made that stale state obvious.
That is the dangerous part of exported environment variables: they belong to a process, not a project directory. In a long-lived shell, changing directories does not reset them. Commands launched from that shell inherit the exported values until you overwrite or unset them.
This kind of mistake is not a Terraform knowledge problem. It is a context problem.
direnv is a small shell extension that addresses that exact failure mode. It loads an authorized environment when you enter a directory and reverses those changes when you leave. A staging repository can declare AWS_PROFILE=staging; a production repository can declare AWS_PROFILE=prod. The directory becomes part of the environment-selection mechanism.
If you want the shell model underneath this first, read Bash Fundamentals. If the command in question is Terraform, pair this guide with Terraform Basics.
The Problem Is Shell State, Not the Command
Suppose a shell already contains:
export AWS_PROFILE=prodAWS documents AWS_PROFILE as the environment variable that selects a named profile for AWS CLI commands. Terraform's S3 backend can also source its profile from AWS_PROFILE, and the AWS provider supports the standard AWS authentication sources.
Now you move into a staging repository:
cd ~/work/staging-infrastructure
terraform plan
terraform applyNothing about cd changes AWS_PROFILE. Unless the repository, wrapper script, prompt, or operator checks the active identity, the shell continues using the exported value.

A safe local reproduction of the failure mode: the shell is inside staging-infrastructure, but its inherited profile is still prod.
The same pattern applies whenever a tool reads identity, region, project, or runtime selection from the environment. The variable names differ. The failure mode is the same: process state outlives the task that created it.
Terminal tabs are separate shell processes
An export in one existing terminal tab does not normally rewrite the environment of another existing tab. The stale-state risk appears when the same long-lived shell moves between projects, or when a new shell inherits configuration from its parent or startup files.
What direnv Actually Does
The official direnv documentation describes this sequence:
- Before each prompt, direnv looks for an
.envrcfile in the current directory and its parent directories. - If the file exists and has been authorized, direnv evaluates it in a Bash subprocess.
- It captures the exported environment difference and applies that difference to the current shell.
- When you leave the directory, direnv reverses the project-specific difference.
That subprocess detail matters. An .envrc can export variables and use direnv's standard-library helpers, but shell aliases and functions are not exported back into the interactive shell.
It also explains why direnv works across shells. Zsh, Bash, Fish, and other supported shells use different hook syntax, while .envrc is evaluated as Bash code and direnv returns an environment diff the host shell can apply.
The Five-Minute Setup
Installation has two parts: install the direnv binary, then add its hook to your shell. The official installation guide lists supported system packages and binary releases.
Confirm the binary is available:
direnv versionThen I I add the documented hook for your shell.
Zsh
I add this to the end of ~/.zshrc:
eval "$(direnv hook zsh)"Bash
I add this to the end of ~/.bashrc:
eval "$(direnv hook bash)"The direnv setup guide specifically says the Bash hook should appear after prompt extensions such as git-prompt or RVM.
Restart the shell after adding the hook. For a new project, create .envrc:
export AWS_PROFILE=stagingOn the next prompt, direnv blocks the file because it has not been trusted yet. I review it, then authorize it:
direnv allow .The authorization step is intentional. An .envrc is executable shell code, so automatically executing every .envrc from every repository would be unsafe. If the file changes, direnv blocks the new version until it is allowed again.
Now verify the value:
echo "$AWS_PROFILE"
aws sts get-caller-identity
The real direnv hook loads the authorized .envrc on entry and replaces the stale profile with the project-scoped value.
AWS documents aws sts get-caller-identity as returning the user or role, account ID, and ARN for the credentials currently in use. The profile name is helpful; the resolved account identity is the stronger check.
Unloading Means Restoring the Previous Environment
This is the nuance most short explanations miss.
direnv unloads the changes introduced by .envrc. It does not promise that every variable becomes empty when you leave. If a value existed before you entered the directory, direnv restores that previous value.
For example:
export AWS_PROFILE=prod
cd ~/work/staging-infrastructure
echo "$AWS_PROFILE"
# staging
cd ..
echo "$AWS_PROFILE"
# prod
Leaving the directory unloads the project environment and restores the shell's previous AWS_PROFILE value.
That behavior is correct: direnv records and reverses an environment diff.
It still prevents the original staging mistake while you are inside the staging repository, because the repository declares the expected profile. But the old production value returns outside that directory. I do not describe direnv as a tool that permanently cleans the entire shell.
A Safer AWS .envrc
Setting AWS_PROFILE is useful, but AWS configuration has precedence rules. AWS documents that environment variables override values loaded from a profile. Stale direct credential variables can therefore defeat the assumption that changing the profile changes the active credentials.
If a project is designed to authenticate through a named profile, make that expectation explicit:
unset AWS_ACCESS_KEY_ID
unset AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN
export AWS_PROFILE=staging
export AWS_REGION=eu-west-2This removes direct AWS credential variables inside the project environment and selects the named staging profile. When you leave, direnv restores the values that existed before entry.
Then I I verify the resolved identity before a mutating command:
aws sts get-caller-identity \
--query '{Account: Account, Arn: Arn}' \
--output tableFor production, keep an independent approval or account check in the deployment workflow. direnv reduces accidental context drift; it does not inspect a Terraform plan, validate the target account, or replace least-privilege credentials.
What Should Go in Git
An AWS profile name, region, project mode, or local tool path is configuration, not a credential. If every contributor should use the same non-secret values, committing .envrc gives the team one reviewable source of truth:
export AWS_PROFILE=staging
export AWS_REGION=eu-west-2I do not commit access keys, secret keys, session tokens, passwords, or API tokens in .envrc. A file being loaded through direnv does not make its contents secret. For the broader security model, see Secrets Management.
If each developer needs different local values, use a documented team convention:
.envrc # ignored, local values
.envrc.example # committed, variable names and safe placeholdersExample .envrc.example:
export AWS_PROFILE=replace-with-your-staging-profile
export AWS_REGION=eu-west-2The example file is a team workflow, not a file direnv loads automatically. Each developer creates the real .envrc, reviews it, keeps it out of version control, and runs direnv allow ..
Review sourced files too
direnv's standard-library documentation warns that files loaded through helpers such as
source_env and source_env_if_exists are not independently checked by the security framework.
Treat every sourced file as executable code and review it before authorizing the parent .envrc.
Where direnv Stops Helping
direnv is a narrow tool. That is a strength, but it creates clear boundaries:
- It cannot prevent a deploy launched from the wrong directory.
- It cannot prove that a local profile name maps to the same account on every machine.
- It cannot stop a user from passing
--profile prodexplicitly on an AWS CLI command. - It cannot override a credential or provider setting explicitly configured at a higher precedence without you accounting for it.
- It cannot review the Terraform plan or add a human approval gate.
- It cannot protect production if staging credentials already have production permissions.
I use it as one layer in a safer deployment path:
- Scope non-secret project configuration with
.envrc. - I use short-lived, least-privilege cloud credentials.
- I verify the resolved account or subscription before mutation.
- I review a saved Terraform plan before applying it.
- Require CI approval for production changes.
HashiCorp's terraform apply documentation says that applying without a saved plan creates a new plan and asks for approval before executing it. That prompt is useful, but an approval is only as good as the context the operator reviews.
A Practical Team Checklist
I use this when adopting direnv:
- Install direnv from an official package or release.
- I add the official hook for the shell you actually use.
- Restart the shell and confirm
direnv version. - Put only non-secret, project-specific values in a shared
.envrc. - I use
.envrc.examplewhen values must remain developer-specific. - I review every
.envrcas executable code before runningdirenv allow. - Re-check the cloud identity with
aws sts get-caller-identity. - I keep production approval and least-privilege controls outside direnv.
The setup is small because the problem is small: a shell does not know which project its environment variables belong to.
direnv gives it that missing directory context.
Set it up before your next context switch between projects, not after the wrong account appears in an apply log.
Official Documentation
- direnv: Installation
- direnv: Shell hook setup
- direnv manual
- direnv standard library manual
- AWS CLI: Configuration and credential files
- AWS CLI: Environment variables and precedence
- AWS CLI:
get-caller-identity - Terraform:
terraform apply - Terraform: S3 backend configuration
- Terraform: Configure providers