πŸ“š Learning Hub
Β· 3 min read

How Environment Variables Actually Work (From Shell to Container)


Every developer uses environment variables. Few understand how they actually propagate from your .env file to your running application. This causes bugs that are maddening to debug β€” β€œit works locally but not in production” is almost always an environment variable problem.

The basics: process inheritance

Every process on your computer has an environment β€” a set of key-value pairs. When a process creates a child process, the child inherits a copy of the parent’s environment.

# Set a variable in your shell
export DATABASE_URL="postgres://localhost:5432/mydb"

# Any process you start from this shell inherits it
node app.js  # process.env.DATABASE_URL exists
python app.py  # os.environ['DATABASE_URL'] exists

Key word: copy. The child gets a snapshot. If the parent changes a variable after the child starts, the child doesn’t see the change. And if the child changes a variable, the parent doesn’t see it either.

Where .env files fit in

.env files are NOT a shell feature. Your operating system knows nothing about them. They’re a convention β€” your application framework reads the file and injects the values into the process environment at startup.

# This does NOT work
cat .env  # DATABASE_URL=postgres://localhost:5432/mydb
node -e "console.log(process.env.DATABASE_URL)"  # undefined

You need a library to load .env files:

  • Node.js: dotenv package (or built-in --env-file flag since Node 20)
  • Python: python-dotenv package
  • Ruby: dotenv gem
  • Docker: --env-file flag or env_file in Compose
// Node.js β€” this is what actually loads .env
require('dotenv').config();
// NOW process.env.DATABASE_URL exists

The loading order (and why it matters)

Most frameworks load environment variables in this order (later overrides earlier):

  1. System environment β€” set by the OS or shell (export VAR=value)
  2. .env file β€” loaded by your framework at startup
  3. .env.local β€” local overrides (gitignored)
  4. .env.development / .env.production β€” environment-specific
  5. Command-line β€” DATABASE_URL=x node app.js

This means a system-level variable can be overridden by your .env file, which can be overridden by .env.local. If you’re getting unexpected values, check all these sources.

Docker and environment variables

Docker containers have their own isolated environment. They do NOT inherit your shell’s environment variables.

export SECRET="my-secret"
docker run myapp
# SECRET does not exist inside the container

You must explicitly pass variables:

# Pass a single variable
docker run -e SECRET="my-secret" myapp

# Pass from your shell (inherits the value)
docker run -e SECRET myapp

# Pass from a file
docker run --env-file .env myapp

Docker Compose:

services:
  app:
    environment:
      - DATABASE_URL=postgres://db:5432/mydb  # hardcoded
      - SECRET  # inherited from host shell
    env_file:
      - .env  # loaded from file

The production mistake everyone makes

Developers put secrets in .env files during development. Then they deploy to production and wonder why the app can’t find them.

In production, environment variables should come from:

  • Cloud provider secrets manager β€” AWS Secrets Manager, GCP Secret Manager, Azure Key Vault
  • Container orchestration β€” Kubernetes Secrets, Docker Swarm secrets
  • CI/CD pipeline β€” GitHub Actions secrets, GitLab CI variables
  • Platform β€” Vercel environment variables, Railway, Render

Never commit .env files with real secrets to git. Use .env.example with placeholder values instead.

Debugging environment variables

# See all environment variables in your shell
env | sort

# Check a specific variable
echo $DATABASE_URL

# See what a process actually receives
node -e "console.log(JSON.stringify(process.env, null, 2))"

# Inside a Docker container
docker exec mycontainer env | sort

# In a running Kubernetes pod
kubectl exec mypod -- env | sort

The one-sentence summary

Environment variables are inherited from parent processes, loaded from .env files by your framework (not the OS), and isolated by containers. When something doesn’t work, check the full chain: shell β†’ framework β†’ container β†’ orchestrator.

Common mistakes that waste hours

Mistake 1: Editing .env and expecting the running app to pick it up. Environment variables are loaded at process start. You need to restart the app.

Mistake 2: Using quotes inconsistently. In .env files, DATABASE_URL=postgres://... and DATABASE_URL="postgres://..." behave differently depending on the library. The dotenv Node.js package strips quotes. Some tools don’t. Be consistent.

Mistake 3: Spaces around =. KEY = value is NOT the same as KEY=value. The first sets a variable named KEY (with a trailing space) to value (with a leading space). Always use KEY=value with no spaces.

Mistake 4: Assuming Docker Compose inherits your shell. environment: - SECRET in docker-compose.yml only works if SECRET is set in the shell where you run docker compose up. If you run it from a CI pipeline or a different terminal, it’s empty.

Related: What are Environment Variables? Β· Environment Variable Not Found β€” fix Β· Free .env File Validator Β· Docker cheat sheet Β· How Docker Networking Actually Works

πŸ“˜