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:
D e f a u l t s ( c o d e ) → C o n f i g f i l e s → E n v v a r s → C o m m a n d - l i n e f l a g s
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:
Shows what variables are needed Documents expected format Provides safe defaults where possible 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:
. . . . e e e e n n n n v v v v . . . . d t s p e e t r v s a o e t g d l i u o n c p g t m i e o n n t
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# Environment variables are the glue between your code and its runtime context. Treat that glue with respect.