Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 036 / 100

Django settings for multiple environments for production

One codebase, many environments.

Expona Admin8 min read

Hook: Why settings matter in production

We've all seen a production incident caused by a debug setting left on, or a database URL hard-coded in source control. Django settings for multiple environments for production is where we stop relying on luck and start building resilient systems. The difference between a smooth deployment and a security breach often comes down to how we manage configuration across development, staging, and production.

The fundamental problem: One codebase, many environments

Our Django application needs to connect to different databases in development versus production. In development, we might use SQLite and have DEBUG enabled. In production, we're on PostgreSQL with DEBUG off, different caching layers, email backends, and third-party API keys. Hardcoding these values in settings.py is a non-starter—we'd need to edit the file on every deployment, and we'd be tempted to commit secrets to version control.

The solution is to externalize configuration so that the same codebase runs correctly across all environments. Environment variables become our source of truth, and our settings module reads from them dynamically.

Read secrets from environment variables

The simplest approach is to read environment variables directly using Python's os.environ. This is the baseline pattern every team should understand.

python
import os

# settings.py

SECRET_KEY = os.environ.get('SECRET_KEY')
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST'),
        'PORT': os.environ.get('DB_PORT', '5432'),
    }
}

CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': os.environ.get('REDIS_URL', 'redis://127.0.0.1:6379/1'),
    }
}

This works, but has friction: we're repeating os.environ.get() constantly, we lack type validation, and handling optional vs. required variables becomes repetitive. For a small project, this is acceptable. For a team maintaining several microservices, we'll quickly want a better abstraction.

Django-environ: The traditional approach

The django-environ package has been the standard in Django teams for years. It provides a cleaner API and automatic type coercion.

python
import environ

# settings.py

env = environ.Env(
    DEBUG=(bool, False),
    SECRET_KEY=(str, ''),
    ALLOWED_HOSTS=(list, []),
)

# Read from .env file (useful in dev) or environment
environ.Env.read_env()

SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG')
ALLOWED_HOSTS = env('ALLOWED_HOSTS')

DATABASES = {
    'default': env.db('DATABASE_URL')
}

CACHES = {
    'default': env.cache('REDIS_URL')
}

The Env object handles casting automatically. If we define DEBUG=(bool, False), the string "true" becomes a Python boolean, and if the variable is missing, we get the default. We can parse connection URLs directly with env.db() and env.cache(), which is far cleaner than assembling dictionaries manually.

A typical .env file for development looks like:

python
DEBUG=True
SECRET_KEY=dev-secret-key-not-for-production
ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
REDIS_URL=redis://127.0.0.1:6379/1

The .env file goes in .gitignore. On production, we set environment variables directly in our deployment platform (AWS Systems Manager Parameter Store, Kubernetes ConfigMaps, Docker Compose, etc.), and django-environ reads them.

Pydantic settings: The modern alternative

Teams already using Pydantic for API validation often prefer pydantic-settings (formerly pydantic.settings in Pydantic v1). It offers stronger type safety and feels more Pythonic for teams comfortable with dataclass-like patterns.

python
from pydantic import Field
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    debug: bool = Field(default=False, alias='DEBUG')
    secret_key: str = Field(alias='SECRET_KEY')
    allowed_hosts: list[str] = Field(default=['localhost'], alias='ALLOWED_HOSTS')
    database_url: str = Field(alias='DATABASE_URL')
    redis_url: str = Field(default='redis://127.0.0.1:6379/1', alias='REDIS_URL')
    
    class Config:
        env_file = '.env'
        case_sensitive = False

settings = Settings()

# In Django settings.py
SECRET_KEY = settings.secret_key
DEBUG = settings.debug
ALLOWED_HOSTS = settings.allowed_hosts

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        # Parse DATABASE_URL manually or use dj-database-url
    }
}

Pydantic-settings excels when we want exhaustive validation. If SECRET_KEY is missing, we get a clear error at startup, not a silent None value. Type hints are first-class, so IDEs and type checkers understand our configuration. For teams using FastAPI alongside Django, Pydantic keeps the validation layer consistent across services.

The trade-off: Pydantic adds a dependency, and parsing complex structures like database connection pools requires more custom code than django-environ's built-in helpers.

Setting DEBUG=False in production

The single most critical setting for production safety is DEBUG=False. With DEBUG enabled in production, Django exposes your full traceback, local variables, SQL queries, and installed apps to anyone who triggers an error. This is a goldmine for attackers.

python
DEBUG = env('DEBUG', default=False)

if DEBUG:
    ALLOWED_HOSTS = ['localhost', '127.0.0.1']
else:
    ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')  # Must be explicitly set
    SECURE_HSTS_SECONDS = 31536000
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True
    SECURE_SSL_REDIRECT = True
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True

When DEBUG=False, we should also enable security headers. The snippet above enables HSTS, forces HTTPS, and ensures cookies are transmitted only over SSL. These settings are incompatible with development (where we're often on HTTP), so we gate them behind the DEBUG flag.

An additional pattern: use different settings modules for each environment.

python
myproject/
  settings/
    __init__.py
    base.py        # Shared configuration
    development.py  # Inherits from base, DEBUG=True
    production.py   # Inherits from base, DEBUG=False
python
# settings/base.py
import environ

env = environ.Env()

SECRET_KEY = env('SECRET_KEY')
INSTALLED_APPS = [...]
MIDDLEWARE = [...]

# settings/production.py
from .base import *

DEBUG = False
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000

Then, in deployment, we pass DJANGO_SETTINGS_MODULE=myproject.settings.production. This makes environment-specific behavior explicit and easier to audit.

Run manage.py check --deploy

Django ships with a built-in deployment checklist. Running python manage.py check --deploy performs 30+ security and configuration checks, catching common mistakes before they reach production.

python
$ python manage.py check --deploy

SystemCheckError: System check identified some issues:

ERRORS:
?: (security.W004) SECURE_HSTS_SECONDS is not set to a recommended value.
  HINT: Generally this should be set to at least 31536000 seconds (1 year).
?: (security.E014) SECURE_SSL_REDIRECT is not True. Redirecting to HTTPS should be enabled.
?: (admin.E408) 'django.contrib.admin' requires 'django.contrib.auth'.

We should integrate this check into our CI/CD pipeline, running it on every deployment:

python
#!/bin/bash
# deploy.sh

python manage.py check --deploy
if [ $? -ne 0 ]; then
  echo "Deployment checks failed"
  exit 1
fi

python manage.py migrate
gunicorn myproject.wsgi

This prevents accidental deployments with insecure settings. For local development, we can skip it or use a separate settings module that doesn't trigger warnings.

Practical example: A complete production setup

Here's how we'd set up a real team project with all three environments:

python
# settings.py (or settings/base.py)

import environ
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env()
environ.Env.read_env(BASE_DIR / '.env')

SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG', default=False)
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['localhost'])

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'rest_framework',
    'corsheaders',
    'myapp',
]

DATABASES = {
    'default': env.db('DATABASE_URL', default='sqlite:///db.sqlite3')
}

CACHES = {
    'default': env.cache('REDIS_URL', default='locmem://')
}

if not DEBUG:
    SECURE_SSL_REDIRECT = True
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True
    SECURE_HSTS_SECONDS = 31536000
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True

Local .env:

python
DEBUG=True
SECRET_KEY=local-dev-key
ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=sqlite:///db.sqlite3
REDIS_URL=locmem://

Staging environment variables (e.g., in Kubernetes):

python
DEBUG=False
SECRET_KEY=
ALLOWED_HOSTS=staging.example.com
DATABASE_URL=postgresql://user:pass@staging-db:5432/mydb
REDIS_URL=redis://staging-redis:6379/0

When we deploy, we read these variables and run:

python
python manage.py check --deploy
python manage.py migrate
gunicorn myproject.wsgi:application --workers 4 --bind 0.0.0.0:8000

Common mistakes

  • Committing .env files: Always add .env to .gitignore. Use a template file like .env.example to document required variables without exposing secrets.
  • Forgetting to set SECRET_KEY in production: If env('SECRET_KEY') returns None, Django will crash. Make it required: SECRET_KEY = env('SECRET_KEY') with no default.
  • Skipping the deployment check: Develop the habit of running check --deploy locally before committing. Automate it in CI.
  • Mixing defaults in settings: If a value has no default and is optional per environment, document it clearly. Empty strings are often safer defaults than None.
  • Not rotating secrets on deployment: Every environment should have unique keys. Reusing a production secret in staging is a liability if staging is compromised.
  • Leaving DEBUG=True by accident: Use environment-based defaults and make DEBUG=False the production norm. Consider raising an exception if DEBUG is True on a production domain.

Key takeaways

  • Read all environment-specific configuration from environment variables, never hardcode secrets or environment-dependent values in source code.
  • Use django-environ for straightforward projects or pydantic-settings if you're already using Pydantic; both eliminate repetitive boilerplate.
  • Always set DEBUG=False in production and enable security headers (SECURE_SSL_REDIRECT, SECURE_HSTS_SECONDS, SESSION_COOKIE_SECURE) in that configuration.
  • Run python manage.py check --deploy before every production deployment; integrate it into your CI/CD pipeline to catch misconfigurations early.
  • Consider splitting settings into base, development, and production modules to make environment-specific behavior explicit and auditable.
  • Use a .env file for local development and set environment variables directly in your deployment platform (Kubernetes, Docker, AWS, etc.) for staging and production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles