Environment Variables¶
Overview¶
This document explores the theoretical foundations and practical implementation patterns for environment-based configuration in Python and Django applications. Following the 12-factor app methodology, environment variables serve as the primary mechanism for separating configuration from code, enabling the same codebase to run across development, staging, and production environments without modification.
The 12-Factor Configuration Principle¶
Why Environment Variables?¶
The 12-factor app methodology establishes that configuration should be stored in the environment, not in code. This principle addresses several critical concerns:
- Security: Secrets and credentials never appear in version control
- Portability: The same codebase runs in any environment
- Clarity: Configuration changes are explicit and auditable
- Separation: Environment-specific concerns are isolated from application logic
Theory: Configuration that varies between deployments (database credentials, API keys, feature flags) belongs in the environment. Configuration that remains constant across deployments (URL routing, installed apps) belongs in code.
Configuration vs Code¶
The distinction between configuration and code follows a simple test: "Would this need to change if we deployed to a different environment?" If yes, it's configuration. If no, it's code.
Configuration (Environment Variables): - Database connection strings - API keys and secrets - Feature flags and toggles - Third-party service endpoints - Debugging and logging levels - Resource limits and timeouts
Code (Django Settings): - Installed apps list - Middleware stack order - URL routing patterns - Template engine configuration - Static files structure
.env File Structure and Usage¶
Local Development Pattern¶
For local development, .env files provide a convenient mechanism to inject environment variables into the application runtime. These files should never be committed to version control.
Theory: .env files are convenience tools for local development only. Production environments should use proper secrets management (AWS SSM Parameter Store, environment variables set by orchestration systems). The .env file simulates what the deployment environment provides natively.
File Organization¶
A well-structured .env file groups related configuration together:
# Django Core Settings
DJANGO_SETTINGS_MODULE=project.settings.development
DJANGO_SECRET_KEY=your-secret-key-here
DEBUG=True
LOG_LEVEL=DEBUG
# Database Configuration
DATABASE_HOST=db
DATABASE_NAME=myapp
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
# Cache and Session
REDIS_URL=redis://redis:6379/0
# AWS Configuration (LocalStack for development)
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT_URL=http://localstack:4566
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
# External Service Credentials
AUTH0_DOMAIN=your-domain.auth0.com
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret
EMAIL_HOST_PASSWORD=your-smtp-password
SENDGRID_API_KEY=your-sendgrid-key
# Feature Flags
FEATURE_NEW_DASHBOARD=false
ALLOW_PYTEST_BYPASS=true
# Monitoring and Observability
SENTRY_DSN=https://your-sentry-dsn
OTEL_EXPORTER_OTLP_ENDPOINT=https://your-otel-endpoint
Grouping Strategy: Related variables are grouped together with comments. This improves readability and helps developers understand the purpose of each configuration block.
Variable Naming Conventions¶
Environment variable naming should follow consistent patterns:
- All uppercase with underscores:
DATABASE_HOST,API_KEY - Prefix for namespacing:
DJANGO_*,AWS_*,REDIS_* - Descriptive and unambiguous:
EMAIL_HOST_PASSWORDnotPASSWORD - No redundant information:
AWS_REGIONnotAWS_REGION_NAME
Theory: Naming conventions reduce cognitive load and prevent variable name collisions. Prefixes act as namespaces, making it clear which system or service a variable configures.
.env.example Documentation Best Practices¶
Purpose and Philosophy¶
The .env.example file serves as the canonical reference for all environment variables required by the application. It's committed to version control and acts as documentation.
Theory: .env.example documents the contract between the application and its environment. It answers: "What configuration does this application need?" Developers can copy it to .env and fill in actual values.
Documentation Strategy¶
Each variable in .env.example should include:
- Descriptive placeholder values that indicate the expected format
- Inline comments explaining where to obtain the value
- Grouping and section headers for organization
- Required vs optional distinction
# Django Settings - REQUIRED
DJANGO_SETTINGS_MODULE=project.settings.development
DJANGO_SECRET_KEY=generate-with-django-admin-startproject
LOG_LEVEL=DEBUG # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL
# Database - REQUIRED
DATABASE_HOST=db
DATABASE_NAME=myapp
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres # Use strong password in production
# Auth0 - REQUIRED - Get these from your Auth0 dashboard
AUTH0_DOMAIN=your-domain.auth0.com
AUTH0_CLIENT_ID=your-oauth-client-id
AUTH0_CLIENT_SECRET=your-oauth-client-secret
AUTH0_AUDIENCE=https://your-domain.auth0.com/api/v2/
# SendGrid Email - OPTIONAL - Only required for email functionality
EMAIL_HOST_PASSWORD=your-sendgrid-smtp-password
SENDGRID_API_KEY=your-sendgrid-api-key
# AWS LocalStack - Development only
AWS_ENDPOINT_URL=http://localstack:4566 # Remove for production
AWS_ACCESS_KEY_ID=test # Development only
AWS_SECRET_ACCESS_KEY=test # Development only
# Feature Flags - OPTIONAL
FEATURE_NEW_DASHBOARD=false # Set to 'true' to enable new dashboard
ALLOW_PYTEST_BYPASS=true # Development only - bypass auth in tests
Theory: Good documentation in .env.example reduces onboarding friction. New developers should be able to understand every variable's purpose and know where to get valid values without asking teammates.
Sensitive Information Handling¶
The .env.example file should never contain real secrets. Instead:
- Use placeholder text:
your-secret-key-here - Provide generation instructions:
generate-with-openssl-rand - Reference documentation:
get-from-aws-console - Indicate where to find values:
see-auth0-dashboard
# Good: Clear placeholder with instructions
DJANGO_SECRET_KEY=generate-a-secure-random-string-50-chars
# Better: With generation command
DJANGO_SECRET_KEY=generate-with-python-secrets-token-urlsafe-64
# Bad: Real secret (never do this)
DJANGO_SECRET_KEY=sk-prod-abc123xyz789
Environment-Specific Variables¶
Development vs Production¶
Different environments have fundamentally different configuration needs. The principle is: development should be permissive and debuggable; production should be secure and performant.
Development Environment:
- DEBUG=True - Enables Django debug pages with stack traces
- LOG_LEVEL=DEBUG - Verbose logging for troubleshooting
- ALLOWED_HOSTS=* - Permissive host validation
- AWS_ENDPOINT_URL=http://localstack:4566 - Points to LocalStack
- SECURE_SSL_REDIRECT=False - No SSL enforcement locally
- ALLOW_PYTEST_BYPASS=true - Testing convenience features
Production Environment:
- DEBUG=False - Disables debug pages (security requirement)
- LOG_LEVEL=INFO - Reduces noise, focuses on important events
- ALLOWED_HOSTS=specific-domains - Strict host validation
- AWS_ENDPOINT_URL= (unset) - Uses real AWS services
- SECURE_SSL_REDIRECT=True - Forces HTTPS
- ALLOW_PYTEST_BYPASS= (unset) - Removes testing bypasses
Theory: The difference between development and production configuration reflects different priorities. Development optimizes for developer experience and debugging; production optimizes for security and performance.
Configuration Layering¶
Environment-specific variables should override base defaults in a predictable hierarchy:
Application defaults (in code)
↓ overridden by
Environment variables (.env or system environment)
↓ overridden by
Command-line arguments (if applicable)
This hierarchy ensures: 1. Sensible defaults exist for all settings 2. Environment-specific overrides are possible 3. Emergency overrides can be applied without changing files
Feature Flags and Environment Toggles¶
Feature flags belong in environment configuration when they:
- Control environment-specific behavior (debug modes, test features)
- Need to change without code deployment
- Differ between environments
# Environment-appropriate feature flags
FEATURE_MAINTENANCE_MODE=false
FEATURE_RATE_LIMITING=true
FEATURE_BACKGROUND_JOBS=true
FEATURE_EXPERIMENTAL_DASHBOARD=false
# Environment behavior toggles
ALLOW_PYTEST_BYPASS=false
ENABLE_QUERY_LOGGING=false
FORCE_STATIC_FILE_SERVING=false
Theory: Feature flags in environment variables are best suited for environment-specific behavior, not business logic features. Business features should use database-backed feature flags (like Django Waffle) that can be toggled per-tenant or per-user.
Never Commit Secrets¶
The Security Imperative¶
Secrets committed to version control represent a permanent security vulnerability. Even if removed in later commits, they remain in Git history forever.
Theory: Git history is immutable and public (for public repositories). Any secret ever committed should be considered compromised and must be rotated immediately. Prevention is the only effective strategy.
What Counts as a Secret?¶
Anything that provides authentication, authorization, or access to systems:
- Passwords and passphrases: Database passwords, admin passwords
- API keys and tokens: SendGrid, Auth0, third-party service keys
- Private keys: SSH keys, SSL certificates, encryption keys
- Session secrets: Django SECRET_KEY, JWT signing keys
- OAuth credentials: Client secrets, refresh tokens
- Database connection strings: If they contain credentials
Prevention Strategies¶
1. .gitignore Configuration
Essential entries for preventing secret leakage:
# Environment files
.env
.env.local
.env.*.local
*.env
# Secret files
secrets/
*.key
*.pem
*.p12
# Configuration with secrets
credentials.json
config.production.yml
Theory: .gitignore is the first line of defense. It prevents accidental commits of common secret-containing files. But it's not foolproof - developers can still force-add ignored files.
2. Pre-commit Hooks
Use tools like detect-secrets or git-secrets to scan commits:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
Theory: Pre-commit hooks provide automated scanning before code reaches the repository. They catch accidental inclusions before they become permanent.
3. Code Review Practices
Human review remains essential:
- Review every line of configuration files
- Question any hardcoded values that look like secrets
- Verify .env.example contains only placeholders
- Check that .env is properly ignored
4. Template Files for Configuration
For configuration files that must be committed:
# config.template.yml (committed)
database:
host: ${DATABASE_HOST}
password: ${DATABASE_PASSWORD}
# config.yml (generated, not committed)
database:
host: db.example.com
password: actual-secret-password
Theory: Template files separate structure (which should be versioned) from values (which should not). Build processes populate templates with environment-specific values.
Emergency Response¶
If secrets are accidentally committed:
- Immediately rotate the compromised secret - Assume it's compromised
- Remove from current codebase - But it's still in history
- Use git-filter-branch or BFG Repo-Cleaner - To purge from history
- Force push to remote - Requires coordination with team
- Notify team members - They need to re-clone
- Audit for exposure - Check if secret was used maliciously
Theory: Secrets in Git history are permanently compromised. Removal from history is possible but disruptive. Prevention through process and tooling is always preferable.
Loading Patterns¶
python-dotenv¶
The python-dotenv library loads .env files into the environment at application startup.
Basic Usage:
# settings/development.py
from pathlib import Path
from dotenv import load_dotenv
# Load .env file from project root
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
# Now environment variables are available
import os
DATABASE_HOST = os.environ.get('DATABASE_HOST', 'localhost')
Theory: python-dotenv bridges the gap between development and production. In development, it loads from .env. In production, environment variables come from the system. The application code is identical.
Loading Order:
- System environment variables (highest priority)
.envfile (loaded if exists)- Application defaults (lowest priority)
This order ensures production environment variables always win, preventing .env files from overriding production configuration.
When to Load:
# Good: Load early in settings
from dotenv import load_dotenv
load_dotenv() # First line in settings file
# Bad: Load after accessing environment
DATABASE_HOST = os.environ.get('DATABASE_HOST') # Might not exist yet
load_dotenv() # Too late
Theory: Load .env before any code attempts to read environment variables. The earliest point is at the top of your settings module.
direnv¶
direnv is a shell extension that automatically loads environment variables when entering a directory.
Setup:
Theory: direnv provides automatic environment activation without requiring manual source commands. When you cd into the project directory, variables are loaded. When you leave, they're unloaded. This prevents environment pollution.
Advantages: 1. Automatic activation: No manual steps required 2. Shell-agnostic: Works with bash, zsh, fish 3. Per-directory isolation: Each project has its own environment 4. Works with all tools: Not just Python - works for any command
Disadvantages: 1. Requires installation: Not available by default 2. Shell integration: Must be configured in shell RC files 3. Security prompting: Asks for approval on first use (security feature)
Comparison with python-dotenv:
| Feature | python-dotenv | direnv |
|---|---|---|
| Scope | Python application only | All shell commands |
| Timing | Application startup | Directory entry |
| Installation | pip install | System package |
| Automation | Manual load in code | Automatic on cd |
| Best for | Application runtime | Development workflow |
Theory: direnv and python-dotenv solve different problems. direnv provides shell-level environment activation for development workflow. python-dotenv provides application-level environment loading for runtime configuration. They can be used together.
Django-environ Alternative¶
django-environ provides a higher-level API for environment variable parsing:
import environ
env = environ.Env(
DEBUG=(bool, False),
DATABASE_URL=(str, 'postgresql://localhost/dbname'),
)
# Read .env file
environ.Env.read_env()
# Use with type casting
DEBUG = env('DEBUG')
DATABASE_URL = env('DATABASE_URL')
Theory: django-environ adds type safety and validation to environment variables. Instead of os.environ.get() returning strings, you get typed values. This prevents runtime errors from misconfigured environment variables.
Trade-offs: - Pro: Type safety, better error messages, URL parsing - Pro: Explicit defaults in one place - Con: Additional dependency - Con: Another abstraction layer to understand
For small teams in 2025, python-dotenv + os.environ.get() is often sufficient. Add django-environ if you have complex configuration needs or want stronger type safety.
Configuration Validation¶
Startup Validation¶
Applications should validate configuration at startup, not at runtime when the config is needed.
# settings/__init__.py
import os
import sys
REQUIRED_VARS = [
'DJANGO_SECRET_KEY',
'DATABASE_HOST',
'DATABASE_NAME',
]
missing_vars = [var for var in REQUIRED_VARS if not os.environ.get(var)]
if missing_vars:
print(f"ERROR: Missing required environment variables: {missing_vars}")
print("Please check your .env file against .env.example")
sys.exit(1)
Theory: Fail fast at startup, not during request handling. If critical configuration is missing, the application shouldn't start. This prevents partial functionality and confusing runtime errors.
Type Validation¶
Environment variables are always strings. Type conversion should happen at the configuration boundary:
# Good: Explicit type conversion with validation
import os
def get_bool_env(var_name: str, default: bool = False) -> bool:
"""Parse boolean environment variable."""
value = os.environ.get(var_name, str(default))
return value.lower() in ('true', '1', 'yes', 'on')
def get_int_env(var_name: str, default: int = 0) -> int:
"""Parse integer environment variable."""
value = os.environ.get(var_name, str(default))
try:
return int(value)
except ValueError:
raise ValueError(f"{var_name} must be an integer, got: {value}")
DEBUG = get_bool_env('DEBUG', False)
MAX_CONNECTIONS = get_int_env('MAX_CONNECTIONS', 10)
Theory: Type conversion should be centralized and explicit. Helper functions provide consistent parsing logic and clear error messages when values are malformed.
Mermaid Diagrams¶
Configuration Loading Flow¶
graph TD
A[Application Startup] --> B{.env file exists?}
B -->|Yes| C[python-dotenv loads .env]
B -->|No| D[Skip .env loading]
C --> E[Merge with system environment]
D --> E
E --> F[Django settings import]
F --> G{Required vars present?}
G -->|No| H[Exit with error]
G -->|Yes| I[Application runs]
Environment Variable Hierarchy¶
graph LR
A[Application Defaults] -->|overridden by| B[.env file]
B -->|overridden by| C[System Environment]
C -->|overridden by| D[Command-line Args]
style D fill:#90EE90
style A fill:#FFB6C1
Development vs Production Configuration¶
graph TB
subgraph Development
D1[.env file] --> D2[LocalStack AWS]
D1 --> D3[Debug Mode ON]
D1 --> D4[Verbose Logging]
end
subgraph Production
P1[AWS SSM Parameters] --> P2[Real AWS Services]
P1 --> P3[Debug Mode OFF]
P1 --> P4[Structured Logging]
end
style Development fill:#E3F2FD
style Production fill:#FFEBEE
Related Documentation¶
- SSM Parameters - Production secrets management
- Secrets Management - Security best practices
- Django Settings - Django-specific configuration patterns
Next Steps¶
- Review your project's
.env.exampleagainst your actual.envto find undocumented variables - Add comments explaining where to obtain each variable's value
- Set up
direnvfor automatic environment loading during development - Implement startup validation for required environment variables
- Configure pre-commit hooks to prevent secret commits
- Document your configuration in team onboarding materials
Configuration Philosophy
Environment variables are the interface between your application and its runtime environment. A well-designed configuration system makes deployment trivial and reduces environment-specific bugs. Invest in clear documentation and validation.
Common Pitfalls
- Mixing configuration and secrets in code
- Forgetting to document variables in
.env.example - Using different variable names across environments
- Failing to validate configuration at startup
- Committing
.envfiles to version control
Security Critical
Never commit secrets to version control. Never. Not even in private repositories. Once committed, assume the secret is compromised and must be rotated. Use .gitignore, pre-commit hooks, and code review to prevent accidents.