Settings Per Environment
Structure settings so dev, staging and production diverge cleanly, secrets stay out of git, and 12-factor config comes from the environment.
A single settings.py with if DEBUG: branches scattered through it is a liability — it is how a debug toolbar or a test database ends up in production. Mature projects split configuration so each environment is explicit and secrets never touch version control.
Two proven approaches
- A settings package —
settings/base.pyholds the shared bulk;dev.pyandprod.pyeachfrom .base import *and override. Select one withDJANGO_SETTINGS_MODULE=myproject.settings.prod. - 12-factor / env vars — one settings file that reads everything variable from the environment (
os.environ, or a helper likedjango-environ). This is the cloud-native default and pairs well with the package split.
The non-negotiables
SECRET_KEY, database passwords, and API tokens come from the environment — never a literal in a committed file.DEBUGdefaults toFalseand is opt-in, so a misconfigured box fails safe rather than leaking tracebacks.ALLOWED_HOSTSis set explicitly in production. Keep a.env.examplein git documenting the required variables, and the real.envin.gitignore.
Validate early: read required settings at startup and raise a clear error if one is missing, so a deploy fails loudly at boot instead of mysteriously at the first request.
Example
# settings/base.py — everything shared, secrets pulled from the environment
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
def env(key, default=None, required=False):
val = os.environ.get(key, default)
if required and val is None:
raise RuntimeError(f"Missing required setting: {key}") # fail LOUD at boot
return val
SECRET_KEY = env("DJANGO_SECRET_KEY", required=True)
# Fail-safe: only True when explicitly "1". Note: the string "False" is truthy!
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = env("DJANGO_ALLOWED_HOSTS", "").split(",") if env("DJANGO_ALLOWED_HOSTS") else []
# settings/dev.py
from .base import * # noqa
DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
INSTALLED_APPS += ["debug_toolbar"]
# settings/prod.py
from .base import * # noqa
DEBUG = False # never overridden to True here
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Select per environment:
# export DJANGO_SETTINGS_MODULE=myproject.settings.prodWhen to use it
- A developer uses django-environ to parse a DATABASE_URL string so settings.py contains no raw credentials.
- A team uses a .env.example file checked into git and a .env file excluded via .gitignore for local overrides.
- A DevOps engineer passes all secrets as environment variables in a Kubernetes Secret manifest so nothing is hard-coded.
More examples
Use django-environ for DATABASE_URL
Loads all settings from environment variables or a .env file using django-environ's typed API.
# pip install django-environ
import environ
env = environ.Env(DEBUG=(bool, False))
environ.Env.read_env() # reads .env file
DEBUG = env("DEBUG")
SECRET_KEY = env("SECRET_KEY")
DATABASES = {"default": env.db("DATABASE_URL")}.env.example for onboarding
Commits a template .env.example with placeholder values so developers know which vars to set locally.
# .env.example (commit this)
SECRET_KEY=change-me
DEBUG=True
DATABASE_URL=postgres://user:pass@localhost/mydb
# .gitignore
.envOverride settings per test
Uses @override_settings to swap the email backend to an in-memory one during unit tests.
from django.test import TestCase, override_settings
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
class EmailTest(TestCase):
def test_sends_welcome_email(self):
from django.core import mail
trigger_welcome_email("[email protected]")
self.assertEqual(len(mail.outbox), 1)
Discussion