Environment variables are the standard way to configure applications across environments. They’re simple, universal, and supported everywhere. But like any tool, they can be misused. Here’s how to do them right.
The Basics#
Environment variables are key-value pairs available to your process:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # Setting them
export DATABASE_URL="postgres://localhost/myapp"
export LOG_LEVEL="info"
# Using them (Bash)
echo $DATABASE_URL
# Using them (Python)
import os
db_url = os.environ.get("DATABASE_URL")
# Using them (Node.js)
const dbUrl = process.env.DATABASE_URL;
|
Naming Conventions#
Be consistent. A common pattern:
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_LOG_LEVEL=...
# Use SCREAMING_SNAKE_CASE
DATABASE_URL=... # Good
databaseUrl=... # Bad (case sensitivity varies by OS)
database-url=... # Bad (hyphens cause issues)
# Be descriptive
MYAPP_DB_POOL_SIZE=10 # Clear
MYAPP_DPS=10 # Cryptic
|
Required vs Optional#
Make it clear which variables are required:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| import os
import sys
def get_required_env(name: str) -> str:
"""Get required environment variable or exit."""
value = os.environ.get(name)
if not value:
print(f"Error: {name} environment variable is required", file=sys.stderr)
sys.exit(1)
return value
def get_optional_env(name: str, default: str = None) -> str:
"""Get optional environment variable with default."""
return os.environ.get(name, default)
# Usage
DATABASE_URL = get_required_env("DATABASE_URL") # Fails fast if missing
LOG_LEVEL = get_optional_env("LOG_LEVEL", "info") # Falls back to default
|
Fail fast. Check all required variables at startup, not when first used. A missing DATABASE_URL should crash immediately, not 30 minutes later when someone makes a request.
Document Everything#
Create a .env.example file that documents every variable:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # .env.example - Copy to .env and fill in values
# Required
DATABASE_URL= # PostgreSQL connection string
SECRET_KEY= # 32+ character random string for encryption
# Optional
LOG_LEVEL=info # debug, info, warn, error
CACHE_TTL=300 # Cache TTL in seconds (default: 300)
MAX_CONNECTIONS=10 # Database pool size (default: 10)
# Feature flags
ENABLE_NEW_CHECKOUT=false
ENABLE_DARK_MODE=true
|
This serves as documentation AND a template for new developers.
Type Conversion#
Environment variables are always strings. Convert them safely:
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
| import os
def env_bool(name: str, default: bool = False) -> bool:
"""Parse boolean environment variable."""
value = os.environ.get(name, "").lower()
if value in ("true", "1", "yes", "on"):
return True
if value in ("false", "0", "no", "off"):
return False
return default
def env_int(name: str, default: int = None) -> int:
"""Parse integer environment variable."""
value = os.environ.get(name)
if value is None:
return default
try:
return int(value)
except ValueError:
raise ValueError(f"{name} must be an integer, got: {value}")
def env_list(name: str, default: list = None) -> list:
"""Parse comma-separated list environment variable."""
value = os.environ.get(name)
if value is None:
return default or []
return [item.strip() for item in value.split(",")]
# Usage
DEBUG = env_bool("DEBUG", False)
POOL_SIZE = env_int("POOL_SIZE", 10)
ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", ["localhost"])
|
Secrets Need Special Care#
Environment variables for secrets have risks:
1
2
3
4
5
6
7
8
9
| # Risk 1: Visible in process list
$ ps aux -e | grep python
# Shows all environment variables
# Risk 2: Logged accidentally
logger.info(f"Config: {os.environ}") # Oops, logged secrets
# Risk 3: Inherited by child processes
subprocess.run(["some-script.sh"]) # Inherits all env vars
|
Mitigations:
1
2
3
4
5
6
7
8
9
10
| # Use file-based secrets in production
def get_secret(name: str) -> str:
# Try file first (Docker secrets, Kubernetes secrets)
file_path = os.environ.get(f"{name}_FILE")
if file_path and os.path.exists(file_path):
return open(file_path).read().strip()
# Fall back to env var for development
return os.environ.get(name, "")
DATABASE_PASSWORD = get_secret("DATABASE_PASSWORD")
|
For production, use a secrets manager (Vault, AWS Secrets Manager) rather than plain environment variables.
Local Development: .env Files#
Use .env files locally, never in production:
1
2
3
4
| # .env (git-ignored!)
DATABASE_URL=postgres://localhost/myapp_dev
SECRET_KEY=dev-secret-not-for-production
DEBUG=true
|
1
2
3
| # Load .env file in development
from dotenv import load_dotenv
load_dotenv() # Loads .env into os.environ
|
Critical: Add .env to .gitignore. Never commit real secrets.
1
2
3
4
| # .gitignore
.env
.env.local
.env.*.local
|
Validation at Startup#
Validate all configuration when your app starts:
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
46
47
| from dataclasses import dataclass
from typing import Optional
import os
import sys
@dataclass
class Config:
database_url: str
secret_key: str
log_level: str = "info"
debug: bool = False
pool_size: int = 10
def __post_init__(self):
errors = []
if not self.database_url:
errors.append("DATABASE_URL is required")
if not self.secret_key:
errors.append("SECRET_KEY is required")
elif len(self.secret_key) < 32:
errors.append("SECRET_KEY must be at least 32 characters")
if self.log_level not in ("debug", "info", "warn", "error"):
errors.append(f"LOG_LEVEL must be debug/info/warn/error, got: {self.log_level}")
if self.pool_size < 1:
errors.append(f"POOL_SIZE must be positive, got: {self.pool_size}")
if errors:
for error in errors:
print(f"Config error: {error}", file=sys.stderr)
sys.exit(1)
@classmethod
def from_env(cls) -> "Config":
return cls(
database_url=os.environ.get("DATABASE_URL", ""),
secret_key=os.environ.get("SECRET_KEY", ""),
log_level=os.environ.get("LOG_LEVEL", "info"),
debug=os.environ.get("DEBUG", "").lower() in ("true", "1"),
pool_size=int(os.environ.get("POOL_SIZE", "10")),
)
# Validate on import
config = Config.from_env()
|
Docker and Containers#
Pass environment variables to containers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Command line
docker run -e DATABASE_URL="..." -e LOG_LEVEL="info" myapp
# From file
docker run --env-file .env myapp
# Docker Compose
services:
app:
environment:
- DATABASE_URL=postgres://db/myapp
- LOG_LEVEL=info
env_file:
- .env.production
|
Quick Checklist#
Environment variables are boring infrastructure. That’s good—boring means reliable. Get them right once and forget about them.