🌍 Environment Configuration
Overview
Django-CFG uses pydantic-settings for modern, type-safe environment configuration. Configuration is loaded from environment variables and .env files automatically.
Simple & Clean No YAML files, no complex loaders. Just ENV variables, .env files, and defaults in code.
Configuration Priority
Priority order (highest to lowest):
- 🥇 System environment variables (Docker, K8s, CI/CD)
- 🥈 .env file (local development)
- 🥉 Default values in code (fallback)
Environment Detection
Setting Environment Mode
Set ONE of these environment variables:
# Development (default if nothing set)
IS_DEV=true
# Production
IS_PROD=true
# Testing (auto-detected from pytest)
IS_TEST=trueAuto-detection If no environment variable is set, development mode is used by default.
Environment Mode API
Basic Usage
from api.environment import env
# Check environment mode
if env.env.is_prod:
print("Running in production!")
if env.env.is_dev:
print("Running in development!")
if env.env.is_test:
print("Running tests!")
# Get environment name
print(env.env.env_mode) # "development", "production", or "test"In Django Settings
from api.environment import env
# Use in settings.py
DEBUG = env.debug if env.env.is_dev else False
ALLOWED_HOSTS = ['*'] if env.env.is_dev else env.security_domains
# Different database per environment
DATABASES = {
"default": DatabaseConfig.from_url(
url=env.database.url
).to_django_dict()
}Configuration Structure
Environment Loader
# api/environment/loader.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseConfig(BaseSettings):
"""Database configuration"""
url: str = Field(
default="sqlite:///db/default.sqlite3",
description="Database connection URL"
)
model_config = SettingsConfigDict(
env_prefix="DATABASE__",
env_nested_delimiter="__",
)
class EmailConfig(BaseSettings):
"""Email configuration"""
backend: str = Field(default="console")
host: str = Field(default="localhost")
port: int = Field(default=587)
username: str | None = Field(default=None)
password: str | None = Field(default=None)
use_tls: bool = Field(default=True)
model_config = SettingsConfigDict(
env_prefix="EMAIL__",
env_nested_delimiter="__",
)
class EnvironmentConfig(BaseSettings):
"""Complete environment configuration"""
# Core Django settings
secret_key: str = Field(
default="django-cfg-dev-key-change-in-production-min-50-chars"
)
debug: bool = Field(default=True)
# Nested configs
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
email: EmailConfig = Field(default_factory=EmailConfig)
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_nested_delimiter="__",
case_sensitive=False,
extra="ignore",
)
# Global instance
env = EnvironmentConfig()Using Environment Variables
ENV Variable Notation
Use double underscore (__) to access nested configurations:
# Flat config
DEBUG=true
SECRET_KEY="my-secret-key-min-50-chars-for-production-use"
# Nested config: email.host
EMAIL__HOST=smtp.gmail.com
EMAIL__PORT=587
EMAIL__USE_TLS=true
# Nested config: database.url
DATABASE__URL=postgresql://user:pass@localhost:5432/mydb
# Nested config: api_keys.openai
API_KEYS__OPENAI=sk-proj-xxx.env File Example
# === Environment Mode ===
IS_DEV=true
# === Core Django Settings ===
SECRET_KEY="your-secret-key-minimum-50-characters-long"
DEBUG=true
# === Database ===
DATABASE__URL="postgresql://postgres:postgres@localhost:5432/djangocfg"
# === Email Configuration ===
EMAIL__BACKEND="smtp"
EMAIL__HOST="smtp.gmail.com"
EMAIL__PORT=587
EMAIL__USERNAME="[email protected]"
EMAIL__PASSWORD="your-password"
EMAIL__USE_TLS=true
# === Cache ===
REDIS_URL="redis://localhost:6379/0"
# === API Keys ===
API_KEYS__OPENROUTER="sk-or-xxx"
API_KEYS__OPENAI="sk-proj-xxx"Environment-Specific Defaults
You can set different defaults in code based on environment:
Database
class DatabaseConfig(BaseSettings):
url: str = Field(
# SQLite for dev, override for prod
default="sqlite:///db/default.sqlite3"
)
model_config = SettingsConfigDict(
env_prefix="DATABASE__",
env_nested_delimiter="__",
)Production override:
DATABASE__URL="postgresql://prod-user:[email protected]:5432/prod_db"class EmailConfig(BaseSettings):
backend: str = Field(
# Console backend for dev (prints to terminal)
default="console"
)
model_config = SettingsConfigDict(
env_prefix="EMAIL__",
env_nested_delimiter="__",
)Production override:
EMAIL__BACKEND="smtp"
EMAIL__HOST="smtp.sendgrid.net"Security
class EnvironmentConfig(BaseSettings):
secret_key: str = Field(
# Dev key (never use in production!)
default="django-cfg-dev-key-change-in-production-min-50-chars"
)
debug: bool = Field(default=True)Production override:
SECRET_KEY="prod-secret-key-from-secrets-manager-min-50-chars"
DEBUG=falseDocker & Production
Docker Compose
services:
django:
environment:
# Environment mode
IS_PROD: "true"
# Core settings
DEBUG: "false"
SECRET_KEY: "${SECRET_KEY}"
# Database
DATABASE__URL: "postgresql://user:pass@postgres:5432/db"
# Cache
REDIS_URL: "redis://redis:6379/0"
# Email
EMAIL__BACKEND: "smtp"
EMAIL__HOST: "smtp.example.com"Kubernetes ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: django-config
data:
IS_PROD: "true"
DEBUG: "false"
DATABASE__URL: "postgresql://user:pass@postgres:5432/db"
REDIS_URL: "redis://redis:6379/0"Secrets Management
Never commit secrets Use environment variables or secrets managers for sensitive data:
- Kubernetes Secrets
- AWS Secrets Manager
- HashiCorp Vault
- .env file (add to .gitignore!)
# api/environment/.env (gitignored!)
SECRET_KEY="prod-key-from-secrets-manager"
DATABASE__URL="postgresql://user:[email protected]:5432/db"
EMAIL__PASSWORD="email-password-from-vault"
API_KEYS__OPENAI="sk-proj-xxx-from-secrets"Validation
Pydantic validates types automatically:
# ✅ Valid
EMAIL__PORT=587 # Converted to int
# ❌ Invalid - raises ValidationError
EMAIL__PORT=abc # Not a number!
# ✅ Valid
DEBUG=true # Converted to bool
# ❌ Invalid
SECRET_KEY="short" # Less than 50 charsCustom Validators
from pydantic import field_validator
class EnvironmentConfig(BaseSettings):
secret_key: str
@field_validator("secret_key")
def validate_secret_key(cls, v):
if len(v) < 50:
raise ValueError("SECRET_KEY must be at least 50 characters")
if "django-insecure" in v:
raise ValueError("Insecure secret key detected!")
return vMigration from YAML
Migrating from old YAML configs?
Old approach used config.dev.yaml, config.prod.yaml, etc.
New approach: Everything via ENV variables!
Before (YAML)
secret_key: "my-secret-key"
debug: false
database:
url: "postgresql://user:pass@localhost:5432/db"
email:
backend: "smtp"
host: "smtp.example.com"After (ENV)
SECRET_KEY="my-secret-key"
DEBUG=false
DATABASE__URL="postgresql://user:pass@localhost:5432/db"
EMAIL__BACKEND="smtp"
EMAIL__HOST="smtp.example.com"Benefits:
- ✅ Simpler - one configuration method
- ✅ 12-factor app compliant
- ✅ Works everywhere (Docker, K8s, CI/CD)
- ✅ No file management overhead
Best Practices
1. Use .env for Local Development
# api/environment/.env (gitignored)
IS_DEV=true
DATABASE__URL="postgresql://localhost:5432/dev_db"
DEBUG=true2. Use System ENV for Production
# Set in Docker/K8s/CI
export IS_PROD=true
export SECRET_KEY="prod-secret-from-vault"
export DATABASE__URL="postgresql://prod-db:5432/db"3. Keep Defaults Safe
# Good defaults for development
class EnvironmentConfig(BaseSettings):
debug: bool = Field(default=True)
database: DatabaseConfig = Field(
default_factory=lambda: DatabaseConfig(
url="sqlite:///db/dev.sqlite3"
)
)4. Validate in Production
from api.environment import env
# Validate critical settings
if env.env.is_prod:
assert len(env.secret_key) >= 50, "Production secret key too short!"
assert not env.debug, "DEBUG must be False in production!"
assert "sqlite" not in env.database.url.lower(), "Use PostgreSQL in production!"Troubleshooting
Environment not detected correctly
from api.environment import env
# Check what was detected
print(f"Environment: {env.env.env_mode}")
print(f"IS_DEV: {env.env.is_dev}")
print(f"IS_PROD: {env.env.is_prod}")
print(f"IS_TEST: {env.env.is_test}")ENV variables not loading
- Check
.envfile location (should be inapi/environment/) - Verify variable naming (use
__for nesting) - Check for typos (case-insensitive but must match structure)
Type conversion errors
# Wrong
EMAIL__PORT=abc # ❌ Not a number
# Correct
EMAIL__PORT=587 # ✅ Valid integer