PROFESSIONAL DJANGO ENGINEERING SERIES #2
One settings file for all environments is a production incident waiting to happen. Here is the professional split that prevents it.
Let me guess. Your settings.py has a block that checks os.environ.get('DEBUG'). It has database configuration for two different backends. It has a comment that says # only in production next to something that also runs locally. And somewhere near the top there is a SECRET_KEY that has been in version control since the project started.
This is the natural end state of a single settings file. It is not a failure of discipline, it is the predictable outcome of a structure that was never designed to handle multiple environments. Here is the fix.
The rule is simple and absolute: no secret, credential, or environment-specific value belongs in a file that is committed to version control. Ever. Without exception.
The Four-File Split
Instead of one settings.py, you maintain four files in a config/settings/ directory:
base.py — everything that is the same across all environments: installed apps, middleware, template configuration, authentication backends.
local.py — development overrides: DEBUG=True, SQLite or local Postgres, console email backend, debug toolbar.
production.py — production configuration: HTTPS enforcement, real database URL, S3 storage, structured logging.
test.py — test-specific: in-memory SQLite, MD5 password hasher for speed, local email backend.
base.py — The foundation
The base file contains configuration that never changes between environments. Crucially, it reads secrets from the environment rather than defining them inline. This is where django-environ earns its place:
# config/settings/base.py
import environ
from pathlib import Path
env = environ.Env()
BASE_DIR = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(BASE_DIR / 'apps'))
# Read from environment — never from the file itself
SECRET_KEY = env('DJANGO_SECRET_KEY')
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[])
# Split INSTALLED_APPS by origin — immediately readable
DJANGO_APPS = ['django.contrib.admin', 'django.contrib.auth', ...]
THIRD_PARTY_APPS = ['rest_framework', 'celery', ...]
LOCAL_APPS = ['users', 'orders', 'products']
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
AUTH_USER_MODEL = 'users.User'
local.py — Development overrides
# config/settings/local.py
from .base import * # noqa
from .base import env
DEBUG = True
SECRET_KEY = env('DJANGO_SECRET_KEY', default='local-dev-key-not-for-production')
ALLOWED_HOSTS = ['localhost', '127.0.0.1']
DATABASES = {'default': env.db('DATABASE_URL', default='sqlite:///db.sqlite3')}
# Emails print to the console during development
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Debug toolbar
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE.insert(1, 'debug_toolbar.middleware.DebugToolbarMiddleware')
INTERNAL_IPS = ['127.0.0.1']
production.py — Hardened for real traffic
# config/settings/production.py
from .base import * # noqa
from .base import env
DEBUG = False
SECRET_KEY = env('DJANGO_SECRET_KEY') # required — no default
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS') # required
DATABASES = {'default': env.db('DATABASE_URL')}
DATABASES['default']['CONN_MAX_AGE'] = env.int('CONN_MAX_AGE', default=60)
# HTTPS enforcement
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# Email via SMTP
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = env('EMAIL_HOST')
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD')
Secrets Management: the .env Pattern
In development, secrets live in a .env file in the project root. This file is in .gitignore and never committed. Instead, you commit a .env.example file with placeholder values that documents every variable the project needs:
# .env.example — commit this file
DJANGO_SECRET_KEY=your-secret-key-here
DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=postgres://user:password@localhost:5432/myproject
REDIS_URL=redis://localhost:6379/0
EMAIL_HOST=smtp.example.com
EMAIL_HOST_PASSWORD=your-password-here
⚠ Your SECRET_KEY is probably in your git history
If you ever committed a real SECRET_KEY to version control, it is compromised regardless of whether you have since removed it. Git history is permanent. Generate a new key immediately using: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
Then rotate it in production and ensure every developer generates their own local key rather than sharing one.
One Final Step: Run the Deploy Check
Before every production deployment, run python manage.py check --deploy. Django will verify your settings against a list of known security requirements and flag anything that is misconfigured. It takes ten seconds and has caught real issues in real deployments. Make it part of your CI pipeline.
The split settings pattern is one of those changes that feels like overhead until the first time it prevents a SECRET_KEY leak, an accidental DEBUG=True in production, or an hours-long search for why the staging environment is behaving differently from production. After that, it feels obvious.
Top comments (0)