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:

  • All required variables validated at startup
  • .env files in .gitignore
  • .env.example committed with placeholder values
  • Secrets in secret management (not plain env vars in production)
  • Consistent naming convention
  • Documentation for each variable
  • No environment branching in code (all values explicit per environment)

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.