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

  • All variables use SCREAMING_SNAKE_CASE
  • App prefix prevents collisions (MYAPP_*)
  • Required variables fail fast at startup
  • .env.example documents every variable
  • .env is in .gitignore
  • Secrets use file-based injection in production
  • Type conversion is explicit and validated
  • Validation happens at startup, not runtime

Environment variables are boring infrastructure. That’s good—boring means reliable. Get them right once and forget about them.