Environment variables seem trivial. Set a value, read it in code. Done.
Then you deploy to production and realize the staging database URL leaked into prod. Or someone commits a .env file with API keys. Or your Docker container starts with 47 environment variables and nobody knows which ones are actually required.
Here’s how to do it properly.
The Basics: Reading Environment Variables#
Every language has a way to read environment variables:
1
2
3
4
5
6
7
8
9
10
| import os
# Basic read (returns None if not set)
database_url = os.environ.get('DATABASE_URL')
# With default value
debug_mode = os.environ.get('DEBUG', 'false') == 'true'
# Required (raises KeyError if not set)
api_key = os.environ['API_KEY']
|
1
2
3
4
| // Node.js
const databaseUrl = process.env.DATABASE_URL;
const debugMode = process.env.DEBUG === 'true';
const apiKey = process.env.API_KEY || (() => { throw new Error('API_KEY required') })();
|
1
2
3
4
5
6
7
8
9
| import "os"
databaseURL := os.Getenv("DATABASE_URL")
debugMode := os.Getenv("DEBUG") == "true"
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
log.Fatal("API_KEY required")
}
|
Validation at Startup#
Don’t wait until you need a variable to discover it’s missing. Validate everything at startup:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| from dataclasses import dataclass
from typing import Optional
import os
import sys
@dataclass
class Config:
database_url: str
redis_url: str
api_key: str
debug: bool = False
log_level: str = "INFO"
@classmethod
def from_env(cls) -> 'Config':
errors = []
database_url = os.environ.get('DATABASE_URL')
if not database_url:
errors.append("DATABASE_URL is required")
redis_url = os.environ.get('REDIS_URL')
if not redis_url:
errors.append("REDIS_URL is required")
api_key = os.environ.get('API_KEY')
if not api_key:
errors.append("API_KEY is required")
if errors:
print("Configuration errors:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
sys.exit(1)
return cls(
database_url=database_url,
redis_url=redis_url,
api_key=api_key,
debug=os.environ.get('DEBUG', 'false').lower() == 'true',
log_level=os.environ.get('LOG_LEVEL', 'INFO'),
)
# Usage
config = Config.from_env()
|
Now if someone deploys without setting DATABASE_URL, the application fails immediately with a clear error—not five minutes later when the first database query runs.
The .env File Pattern#
For local development, .env files are convenient:
1
2
3
4
5
| # .env (DO NOT COMMIT)
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=dev_key_not_for_production
DEBUG=true
|
Load with python-dotenv or similar:
1
2
3
4
| from dotenv import load_dotenv
load_dotenv() # Loads .env into environment
config = Config.from_env()
|
Critical: Add .env to .gitignore. Commit a .env.example instead:
1
2
3
4
5
| # .env.example (safe to commit)
DATABASE_URL=postgresql://localhost:5432/myapp_dev
REDIS_URL=redis://localhost:6379
API_KEY=your_api_key_here
DEBUG=true
|
Docker and Environment Variables#
Pass environment variables to containers:
1
2
3
4
5
6
7
8
9
10
| # docker-compose.yml
services:
app:
image: myapp:latest
environment:
- DATABASE_URL=postgresql://db:5432/myapp
- REDIS_URL=redis://redis:6379
- LOG_LEVEL=INFO
env_file:
- .env.production # Or load from file
|
For production, use secrets management instead of plain environment variables:
1
2
3
4
5
6
7
8
9
10
11
12
| # docker-compose.yml with Docker secrets
services:
app:
image: myapp:latest
environment:
- DATABASE_URL_FILE=/run/secrets/database_url
secrets:
- database_url
secrets:
database_url:
external: true
|
Then read from file in your app:
1
2
3
4
5
6
7
| def get_secret(name: str) -> str:
"""Read from Docker secret file or fall back to env var."""
file_path = os.environ.get(f'{name}_FILE')
if file_path and os.path.exists(file_path):
with open(file_path) as f:
return f.read().strip()
return os.environ.get(name, '')
|
Kubernetes ConfigMaps and Secrets#
In Kubernetes, separate configuration from secrets:
1
2
3
4
5
6
7
8
| # configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: "INFO"
FEATURE_FLAG_X: "true"
|
1
2
3
4
5
6
7
8
9
| # secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
stringData:
DATABASE_URL: "postgresql://user:pass@db:5432/myapp"
API_KEY: "supersecretkey"
|
1
2
3
4
5
6
7
8
9
| # deployment.yaml
spec:
containers:
- name: app
envFrom:
- configMapRef:
name: app-config
- secretRef:
name: app-secrets
|
Naming Conventions#
Consistent naming prevents confusion:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # Prefix with app name to avoid collisions
MYAPP_DATABASE_URL=...
MYAPP_REDIS_URL=...
MYAPP_API_KEY=...
# Use underscores, not hyphens (hyphens are invalid in many shells)
MYAPP_LOG_LEVEL=INFO # Good
MYAPP-LOG-LEVEL=INFO # Bad
# Boolean values: use explicit strings
MYAPP_DEBUG=true # Good
MYAPP_DEBUG=1 # Ambiguous
MYAPP_DEBUG=yes # Ambiguous
|
Environment-Specific Configuration#
Don’t use ENV=production and branch everywhere. Instead, set all values explicitly per environment:
1
2
3
4
5
6
7
8
9
| # staging.env
DATABASE_URL=postgresql://staging-db:5432/myapp
LOG_LEVEL=DEBUG
FEATURE_NEW_UI=true
# production.env
DATABASE_URL=postgresql://prod-db:5432/myapp
LOG_LEVEL=INFO
FEATURE_NEW_UI=false
|
Your code stays simple—no if ENV == 'production' branching. Each environment is fully specified.
Documenting Required Variables#
Create a single source of truth:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| # config.py
"""
Required Environment Variables:
DATABASE_URL (required)
PostgreSQL connection string
Example: postgresql://user:pass@localhost:5432/myapp
REDIS_URL (required)
Redis connection string
Example: redis://localhost:6379
API_KEY (required)
External API authentication key
DEBUG (optional, default: false)
Enable debug mode
LOG_LEVEL (optional, default: INFO)
Logging verbosity: DEBUG, INFO, WARNING, ERROR
"""
|
Or generate documentation from your config class:
1
2
3
4
5
6
| def print_config_help():
print("Required environment variables:")
print()
for field in Config.__dataclass_fields__.values():
required = field.default is dataclasses.MISSING
print(f" {field.name.upper()}: {'required' if required else 'optional'}")
|
The Checklist#
Before deploying:
Environment variables are the interface between your application and its deployment environment. Get them right, and configuration becomes boring—which is exactly what you want.