Environment Variables
PATH walking, dotfile precedence, and the child-inherits-parent rule.
Environment Variables
Environment variables are the most under-respected configuration mechanism in software. They're invisible until they're wrong, at which point they're catastrophically wrong — and the failure modes ("works on my machine, fails in CI") are some of the most painful in engineering.
Analogy
Environment variables are the wallet you hand a child before sending them to the corner store. The child (the process) has the wallet (the env) for that one trip. They can take money out, put change back, even hand the wallet to a kid sister (a child process) — but when they come home, your wallet is unchanged. Every shopping trip starts with a fresh copy. The dotfiles (.zshrc, .bashrc) are the "always-bring-this" notes pinned to the front door — followed every time you leave.
What an env var is
An environment variable is a string-keyed map of strings, attached to a process. Children inherit a copy at fork time. There are no other levels — no namespaces, no nesting, just KEY=VALUE pairs in a flat map.
$ env | head -5
HOME=/Users/alice
SHELL=/bin/zsh
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin
LANG=en_US.UTF-8
USER=alice
A program reads them via process.env.NAME (Node), os.environ["NAME"] (Python), os.Getenv("NAME") (Go), std::env::var("NAME") (Rust).
$PATH — the most important one
$PATH is a colon-separated list of directories. When you type a bare command like mytool, the shell walks $PATH left-to-right and runs the first executable file named mytool it finds.
$ echo $PATH
/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
So mytool will be found at /opt/homebrew/bin/mytool if it exists; otherwise at /usr/local/bin/mytool; otherwise at /usr/bin/mytool; and so on.
Two facts that bite everyone:
- Order matters. If you have two versions of
pythonon your system — one in/opt/homebrew/binand one in/usr/bin— the leftmost wins. This is whywhich pythonis so useful. - Trailing
:or::is a security trap. A trailing colon makes the shell search the current directory, which lets a hostile coworker drop a file calledlsin a shared dir and surprise you. Never include.or empty entries in$PATHon a shared machine.
The single most useful debugging command:
which -a mytool
# /opt/homebrew/bin/mytool
# /usr/local/bin/mytool ← shadowed
which -a lists all matches, in $PATH order. The first one wins; any after that are shadowed.
Dotfile precedence — bash and zsh
The shell reads different config files depending on whether it's a login shell (started at login or with -l) or interactive non-login (a regular terminal tab in iTerm/Terminal):
bash
| Type | Reads (in order) |
|---|---|
| Login | /etc/profile, then first existing of ~/.bash_profile, ~/.bash_login, ~/.profile |
| Non-login interactive | /etc/bash.bashrc, then ~/.bashrc |
| Non-interactive (script) | Whatever $BASH_ENV points at, often nothing |
zsh (macOS default)
| Type | Reads |
|---|---|
| Always | ~/.zshenv (every shell, login or not) |
| Login only | ~/.zprofile |
| Interactive | ~/.zshrc |
Login (after .zshrc) |
~/.zlogin |
The takeaway: put export PATH=... in ~/.zshrc for interactive shells, or ~/.zshenv if you also need it for non-interactive scripts. A common bug is setting PATH only in ~/.zshrc and being surprised it doesn't apply to a cron job (cron runs non-interactive, non-login).
A diagnostic trick:
echo "zshenv loaded" >> ~/.zshenv
echo "zprofile loaded" >> ~/.zprofile
echo "zshrc loaded" >> ~/.zshrc
echo "zlogin loaded" >> ~/.zlogin
# now open a new tab — see which lines printed and in what order
The child-inherits-parent rule
When a process spawns a child (fork/exec), the child gets a copy of the parent's env. Three corollaries:
- A subshell or child program sees the parent's exports. This is why
export DATABASE_URL=...thennode app.jsworks. - Changes the child makes to its env do NOT propagate back to the parent. Source files (
source ./script.sh) are how you propagate — they run in the same shell, not a child. - Once a child runs, you can't modify its env from outside. Tools like
gdbcan hack around this; nothing in normal use does.
This is also why cd inside a script doesn't change your terminal's directory — cd runs in the child, its env (including $PWD) is the child's, and that env evaporates when the child exits.
Setting variables — the mechanics
Three scopes:
# 1) Just for this one command (no leak):
PORT=4000 node app.js
# 2) For this shell session (and its future children):
export PORT=4000
node app.js # sees PORT=4000
zsh # spawn a subshell — also sees PORT=4000
# 3) Forever (write to dotfile):
echo 'export PORT=4000' >> ~/.zshrc
# now restart the terminal, or:
source ~/.zshrc
Without export, a variable is shell-local — your current zsh sees it but child processes do not.
PORT=4000 # NO export — only this shell
node app.js # process.env.PORT is undefined
The 12-factor reason for env vars
The 12-factor app methodology — the consensus playbook for cloud-native services — says:
The twelve-factor app stores config in the environment.
Three reasons:
- Environment is language-agnostic. Every runtime reads env vars;
dotenvlibraries exist for every language. A library you're using, written in a language different from yours, will still respect the env. - Environment travels with the deploy unit, not with the code. Dev, staging, and prod can run the same image with different env values — no code branches per environment.
- Environment is easy to inject from a secrets manager at runtime. AWS Secrets Manager, GCP Secret Manager, 1Password, Doppler — all expose secrets as env vars. The secret never touches disk.
This is why the Heroku/k8s/lambda/cloud-run consensus is "config via env." It's not because env vars are great; it's because they're the smallest possible interface that every system speaks.
Secrets handling
Environment variables make a fine carrier for secrets if you handle the source carefully.
.envfiles in dev — fine, but.gitignorethem. Commit a.env.examplewith placeholder values for newcomers.- CI/CD — set them in the platform's secret store (GitHub Actions secrets, GitLab CI variables). Never echo them in logs. Most CI systems automatically redact known-secret values that appear in output, but don't rely on it.
- Production — inject from a secrets manager at runtime; let the orchestrator (k8s, ECS, Cloud Run) do the injection. The container image must NOT bake secrets in.
- Debugging —
envandprintenvprint everything. If youenv > out.txtto share with a coworker, you've just leaked your secrets. Useenv | grep -v -E "TOKEN|KEY|SECRET|PASSWORD"first.
Tools that pay off
direnv — drops a .envrc file in each project directory. When you cd in, direnv loads it; when you cd out, direnv unloads it. Per-project env without polluting your global shell.
# .envrc
export DATABASE_URL=postgres://localhost/myproj_dev
export DEBUG=1
After direnv allow, every shell that enters this directory sees those vars. Every shell that leaves stops seeing them.
1Password CLI — op run -- node app.js reads secrets from your 1Password vault, injects them as env vars for the child, and they're gone when the child exits. Nothing on disk.
sops — encrypts YAML/JSON files using KMS or age keys. Commit the encrypted form. Tooling decrypts on the way to the env.
dotenv — every language has a port. The standard pattern: require('dotenv').config() at startup loads .env into process.env. Use it in dev, never in prod.
Common bugs and how to spot them
- Works in IDE, fails in CLI — different shell environments. Your IDE may launch a login shell; your terminal may not. Or your IDE injects vars from a config file the terminal doesn't see.
- Cron job has no PATH — cron runs with a minimal env. Set
PATH=...explicitly in the crontab or wrapper script. - Variable set, child doesn't see it — you forgot to
export. Or you set it in.zshrcbut the child is invoked from a non-interactive context. - Two versions of a tool, wrong one runs —
which -ato find the shadowing. Reorder $PATH or remove one. echo $FOOprints empty in a script — the script is its own process. The var must be exported in the parent shell or set in the script itself.
A useful 30-second check
When something env-related goes wrong:
which -a mytool # which $PATH entry is winning?
echo $PATH # is it what I think it is?
env | grep MY_THING # is the var actually set in this shell?
printenv MY_THING # equivalent
That covers 90% of env-var debugging.
What to internalise
- $PATH is left-to-right; first match wins;
which -areveals the order. - Dotfile loading depends on login vs interactive — same shell, different files.
- Children get a copy of parent env; their changes don't escape.
- Use
exportto make vars visible to children. - Production secrets belong in a secrets manager that injects env vars; never in git.
Tools in the wild
5 tools- clidirenvfree tier
Auto-loads per-directory .envrc files when you cd into a project.
- cli
Inject secrets from 1Password into env vars at runtime — never written to disk.
- servicedopplerfree tier
SaaS secrets manager with CLI injection and CI/CD integrations.
- clisopsfree tier
Encrypt secrets in YAML/JSON files using KMS or age — commit the encrypted form.
- librarydotenvfree tier
Load .env files into process.env in Node. Many language ports exist.