foundations · level 9

Environment Variables

PATH walking, dotfile precedence, and the child-inherits-parent rule.

150 XP

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 python on your system — one in /opt/homebrew/bin and one in /usr/bin — the leftmost wins. This is why which python is 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 called ls in a shared dir and surprise you. Never include . or empty entries in $PATH on 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:

  1. A subshell or child program sees the parent's exports. This is why export DATABASE_URL=... then node app.js works.
  2. 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.
  3. Once a child runs, you can't modify its env from outside. Tools like gdb can 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:

  1. Environment is language-agnostic. Every runtime reads env vars; dotenv libraries exist for every language. A library you're using, written in a language different from yours, will still respect the env.
  2. 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.
  3. 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.

  • .env files in dev — fine, but .gitignore them. Commit a .env.example with 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.
  • Debuggingenv and printenv print everything. If you env > out.txt to share with a coworker, you've just leaked your secrets. Use env | 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 CLIop 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 .zshrc but the child is invoked from a non-interactive context.
  • Two versions of a tool, wrong one runswhich -a to find the shadowing. Reorder $PATH or remove one.
  • echo $FOO prints 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 -a reveals 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 export to 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
  • direnvfree 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.

    cli
  • dopplerfree tier

    SaaS secrets manager with CLI injection and CI/CD integrations.

    service
  • sopsfree tier

    Encrypt secrets in YAML/JSON files using KMS or age — commit the encrypted form.

    cli
  • dotenvfree tier

    Load .env files into process.env in Node. Many language ports exist.

    library