Stack: Django/DRF · Project: TicketFlow Status: Published — the same image, three deployments Prerequisite: Lesson 26 — Centralized errors
Objectives
- Apply the 12-factor principle: all configuration lives in the environment, the code doesn't change between dev/staging/prod.
- Organize Django settings per environment without duplication and without
if "prod" in hostname. - Manage secrets with dual rotation (23) and validate configuration at boot, not at 3 a.m.
1. The rule: config ≠ code
The third 12-factor principle: configuration is everything that varies between deployments (DB URL, credentials, DEBUG), and it lives in the environment — environment variables — never in the code. The proof: the same Docker image (40) must deploy to dev, staging and prod changing only variables. If your Dockerfile has ENV DJANGO_SETTINGS_MODULE=prod_settings, it violates the principle: the image already knows where it lives.
An apparent violation that is NOT one: settings.DEBUG = False by default is a safe default in code; the DJANGO_DEBUG var overrides it in dev. Safe defaults in code + differences in environment = the correct combination.
2. Organizing settings
Three formats in the wild: a package with splits, modularized settings, and variables. The splits package (base/dev/prod) fits Django's brain, but invites cross-imports that hide what really varies. The most honest for 12-factor: a single settings.py reading EVERYTHING from the environment with safe defaults, no per-environment branches:
# settings.py — a single module, everything from the environment
import environ
env = environ.Env(
DJANGO_DEBUG=(bool, False), # SAFE default
ALLOWED_HOSTS=(list, []),
DATABASE_URL=(str, ""),
CELERY_BROKER_URL=(str, ""),
PAYMENT_GATEWAY_URL=(str, "https://sandbox.gw.example"),
)
SECRET_KEY = env("DJANGO_SECRET_KEY") # no default: if missing, it explodes at boot
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
DATABASES = {"default": env.db("DATABASE_URL")}
CACHES = {"default": env.cache("REDIS_URL")}The difference with if os.environ.get("IS_PROD"):...: there is no branching here, there is a flat dictionary of "all the config this process may need". The environment decides values; the code decides keys and types. For projects without an extra dependency: django-environ or pydantic-settings — both parse types (DATABASE_URL to a dict, comma-separated lists) and fail with a readable error.
3. TicketFlow's three environments
| Var | Dev | Staging | Prod |
|---|---|---|---|
DJANGO_DEBUG | true | false | false |
ALLOWED_HOSTS | localhost,127.0.0.1 | staging.ticketflow.dev | ticketflow.com |
DATABASE_URL | postgres://dev:dev@localhost/tf | postgres://staging:***@db-staging/tf | postgres://app:***@db-prod/tf |
REDIS_URL | localhost:6379 | redis-staging | redis-prod |
PAYMENT_GATEWAY_URL | sandbox | sandbox | production |
DJANGO_SECRET_KEY | anything | staging secret | prod secret |
FEATURE_WEBHOOKS | true | true | true |
The commit that touches settings.py "to go to production" is the commit that gets forgotten: with 12-factor, going to production = deploying the same image with a different.env/orchestrator vars. Staging with the SAME variables AS PROD but pointing at sandboxes (gateway, SMTP) is the realistic integration test: what staging validates is the CONFIGURATION MATCH, not the value.
4. Secrets and dual rotation
Lesson 23 set the policy; here is the mechanics: secrets do NOT go into the repo (06's .gitignore covers .env), not into the log (45's access log filters SECRET/TOKEN), and not into the Docker image (a docker history filters them out — they go into the runtime environment or the orchestrator's secret manager, 42/43). Dual rotation: SECRET_KEY and PAYMENT_GATEWAY_KEY accept _OLD in settings:
SECRET_KEYS = [env("DJANGO_SECRET_KEY")] + (
[env("DJANGO_SECRET_KEY_OLD")] if env.bool("DJANGO_SECRET_KEY_OLD", default=False) else []
)During rotation the process validates with the new one and accepts the old one; afterwards *_OLD is removed (a short window, planned, with 23's checklist). The identical pattern for third-party webhook keys (17): both active during the window.
5. Fail-fast and validation at boot
The worst moment for a config error is the first client request; the second worst is the deploy; the best is process boot. Validation checklist:
# settings.py (or a check at the end of the module)
if not DEBUG and not SECRET_KEY:
raise ImproperlyConfigured("DJANGO_SECRET_KEY is mandatory outside dev")
if not DEBUG and not ALLOWED_HOSTS:
raise ImproperlyConfigured("empty ALLOWED_HOSTS in production: impossible to serve")More systematic: python manage.py check --deploy (22) in 41's pipeline, and a settings smoke test that instantiates the complete set per fake environment: broken config doesn't reach production because a test fails, not because a user wrote to support.
Self-assessment
- What turns a value into 12-factor "configuration", and what is the same-image proof?
- A single settings.py without branches vs base/dev/prod splits: which readability problem does the former solve?
- Why must staging have the SAME variable STRUCTURE as prod even if the values point to sandboxes?
- Explain dual secret rotation in settings and its window. What happens if you forget to remove
*_OLD? - What is the best moment to detect broken config and how do you guarantee it (checklist + pipeline)?
Continue with the exercises. The solutions only after trying it yourself.