Environment variables seem trivial—just key-value pairs. Then you have 50 of them across 4 environments with secrets mixed in, and suddenly you’re in configuration hell. Here’s how to stay sane.

The Hierarchy

Configuration should flow from least to most specific:

Defaults(code)ConfigfilesEnvvarsCommand-lineflags

Each layer overrides the previous. Environment variables sit near the top—easy to change per environment without touching code.

Local Development

Use .env files with a loader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# .env.example (committed to git)
DATABASE_URL=postgres://localhost/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
DEBUG=true

# .env (gitignored, actual values)
DATABASE_URL=postgres://localhost/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=sk-actual-secret-key
DEBUG=true

Load with your language’s dotenv library:

1
2
3
4
5
6
# Python
from dotenv import load_dotenv
load_dotenv()

import os
db_url = os.environ.get('DATABASE_URL')
1
2
3
// Node.js
require('dotenv').config();
const dbUrl = process.env.DATABASE_URL;
1
2
3
4
# Shell - source or use direnv
source .env
# or
export $(cat .env | xargs)

The .env.example Pattern

Always maintain .env.example:

  1. Shows what variables are needed
  2. Documents expected format
  3. Provides safe defaults where possible
  4. New developers copy it to .env and fill in secrets
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# .env.example
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/dbname
DATABASE_POOL_SIZE=10

# Redis
REDIS_URL=redis://localhost:6379

# API Keys (get from admin)
STRIPE_SECRET_KEY=sk_test_...
SENDGRID_API_KEY=SG....

# Feature flags
ENABLE_NEW_CHECKOUT=false

# Debug (set true for verbose logging)
DEBUG=false

Environment-Specific Files

For multiple environments:

....eeeennnnvvvv....dtspeetrvsaoetgdliuoncpgtmieonnt

Load based on NODE_ENV or similar:

1
2
3
require('dotenv').config({
  path: `.env.${process.env.NODE_ENV || 'development'}`
});

Warning: Never commit .env.production with real secrets. Use a secrets manager for production.

Validation

Fail fast if required variables are missing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import os
import sys

REQUIRED = [
    'DATABASE_URL',
    'REDIS_URL', 
    'SECRET_KEY',
]

missing = [var for var in REQUIRED if not os.environ.get(var)]
if missing:
    print(f"Missing required env vars: {', '.join(missing)}")
    sys.exit(1)

Or use a validation library:

1
2
3
4
5
6
7
8
9
// Node.js with envalid
const envalid = require('envalid');

const env = envalid.cleanEnv(process.env, {
  DATABASE_URL: envalid.url(),
  PORT: envalid.port({ default: 3000 }),
  NODE_ENV: envalid.str({ choices: ['development', 'test', 'production'] }),
  API_KEY: envalid.str(),
});

Secrets vs Config

Treat them differently:

Config (can be in .env files, version control):

  • Feature flags
  • Service URLs (non-sensitive)
  • Timeouts and limits
  • Log levels

Secrets (use a secrets manager):

  • API keys
  • Database passwords
  • Encryption keys
  • OAuth credentials
1
2
3
4
5
6
7
8
# Config - okay in .env
LOG_LEVEL=info
MAX_UPLOAD_SIZE=10MB
FEATURE_NEW_UI=true

# Secrets - use secrets manager
DATABASE_PASSWORD=  # Injected at runtime
API_SECRET_KEY=     # From Vault/AWS Secrets Manager

Docker and Compose

Pass env vars to containers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# docker-compose.yml
services:
  app:
    image: myapp
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
    env_file:
      - .env
      - .env.production

For secrets in production, use Docker secrets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
services:
  app:
    secrets:
      - db_password
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    external: true

Kubernetes

ConfigMaps for config, Secrets for secrets:

1
2
3
4
5
6
7
8
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "info"
  MAX_CONNECTIONS: "100"
1
2
3
4
5
6
7
8
# secret.yaml (values are base64 encoded)
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  DATABASE_PASSWORD: cGFzc3dvcmQxMjM=
1
2
3
4
5
6
7
8
9
# deployment.yaml
spec:
  containers:
    - name: app
      envFrom:
        - configMapRef:
            name: app-config
        - secretRef:
            name: app-secrets

Production Secrets Management

Don’t store production secrets in files. Options:

AWS Secrets Manager / Parameter Store:

1
aws secretsmanager get-secret-value --secret-id prod/myapp/db-password

HashiCorp Vault:

1
vault kv get -field=password secret/prod/database

Environment injection at deploy:

1
2
3
# Kubernetes
kubectl create secret generic app-secrets \
  --from-literal=DB_PASSWORD="$(vault read -field=password secret/db)"

Naming Conventions

Be consistent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Good - clear prefixes
DATABASE_URL=
DATABASE_POOL_SIZE=
DATABASE_TIMEOUT_MS=

REDIS_URL=
REDIS_MAX_CONNECTIONS=

STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=

# Bad - inconsistent
DB_URL=
DatabasePoolSize=
redis-url=
StripeKey=

Conventions:

  • SCREAMING_SNAKE_CASE
  • Prefix by service/category
  • Suffix with type when helpful (_URL, _MS, _COUNT)

Debugging

See what’s set:

1
2
3
4
5
6
7
8
# All env vars
env | sort

# Filter
env | grep DATABASE

# In app, print on startup (not secrets!)
echo "Config: LOG_LEVEL=$LOG_LEVEL, PORT=$PORT"

Check if variable is set vs empty:

1
2
3
4
5
6
7
# Set but empty
export VAR=""
[ -z "$VAR" ] && echo "empty or unset"

# Actually unset
unset VAR
[ -z "${VAR+x}" ] && echo "truly unset"

Common Mistakes

Committing .env to git:

1
2
3
4
# .gitignore
.env
.env.local
.env.*.local

Hardcoding fallbacks for secrets:

1
2
3
4
5
# Bad - secret in code
api_key = os.environ.get('API_KEY', 'sk-default-key')

# Good - fail if missing
api_key = os.environ['API_KEY']  # Raises KeyError if missing

Not validating on startup: App runs for hours, then crashes when it first needs a missing variable.

Mixing secrets and config: Makes rotation harder, increases exposure risk.

The Checklist

  • .env.example documents all variables
  • .env is gitignored
  • Required variables validated at startup
  • Secrets separated from config
  • Production secrets in a secrets manager
  • Consistent naming convention
  • No sensitive values in logs

Environment variables are the glue between your code and its runtime context. Treat that glue with respect.