Skip to content

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:

  1. Security: Secrets and credentials never appear in version control
  2. Portability: The same codebase runs in any environment
  3. Clarity: Configuration changes are explicit and auditable
  4. 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:

  1. All uppercase with underscores: DATABASE_HOST, API_KEY
  2. Prefix for namespacing: DJANGO_*, AWS_*, REDIS_*
  3. Descriptive and unambiguous: EMAIL_HOST_PASSWORD not PASSWORD
  4. No redundant information: AWS_REGION not AWS_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:

  1. Descriptive placeholder values that indicate the expected format
  2. Inline comments explaining where to obtain the value
  3. Grouping and section headers for organization
  4. 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:

  1. Use placeholder text: your-secret-key-here
  2. Provide generation instructions: generate-with-openssl-rand
  3. Reference documentation: get-from-aws-console
  4. 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:

  1. Control environment-specific behavior (debug modes, test features)
  2. Need to change without code deployment
  3. 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:

  1. Passwords and passphrases: Database passwords, admin passwords
  2. API keys and tokens: SendGrid, Auth0, third-party service keys
  3. Private keys: SSH keys, SSL certificates, encryption keys
  4. Session secrets: Django SECRET_KEY, JWT signing keys
  5. OAuth credentials: Client secrets, refresh tokens
  6. 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:

  1. Immediately rotate the compromised secret - Assume it's compromised
  2. Remove from current codebase - But it's still in history
  3. Use git-filter-branch or BFG Repo-Cleaner - To purge from history
  4. Force push to remote - Requires coordination with team
  5. Notify team members - They need to re-clone
  6. 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:

  1. System environment variables (highest priority)
  2. .env file (loaded if exists)
  3. 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:

# .envrc (in project root)
dotenv .env

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

Next Steps

  1. Review your project's .env.example against your actual .env to find undocumented variables
  2. Add comments explaining where to obtain each variable's value
  3. Set up direnv for automatic environment loading during development
  4. Implement startup validation for required environment variables
  5. Configure pre-commit hooks to prevent secret commits
  6. 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 .env files 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.