Module 5 · Architecture and maintainable code

Lesson 27 — Environment configuration (12-factor)

Secrets and settings per environment: the same image, three deployments.

Published
In this lesson
  1. Objectives
  2. 1. The rule: config ≠ code
  3. 2. Organizing settings
  4. 3. TicketFlow's three environments
  5. 4. Secrets and dual rotation
  6. 5. Fail-fast and validation at boot
  7. Self-assessment

Stack: Django/DRF · Project: TicketFlow Status: Published — the same image, three deployments Prerequisite: Lesson 26 — Centralized errors


Objectives

  1. Apply the 12-factor principle: all configuration lives in the environment, the code doesn't change between dev/staging/prod.
  2. Organize Django settings per environment without duplication and without if "prod" in hostname.
  3. 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:

python
# 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

VarDevStagingProd
DJANGO_DEBUGtruefalsefalse
ALLOWED_HOSTSlocalhost,127.0.0.1staging.ticketflow.devticketflow.com
DATABASE_URLpostgres://dev:dev@localhost/tfpostgres://staging:***@db-staging/tfpostgres://app:***@db-prod/tf
REDIS_URLlocalhost:6379redis-stagingredis-prod
PAYMENT_GATEWAY_URLsandboxsandboxproduction
DJANGO_SECRET_KEYanythingstaging secretprod secret
FEATURE_WEBHOOKStruetruetrue

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:

python
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:

python
# 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

  1. What turns a value into 12-factor "configuration", and what is the same-image proof?
  2. A single settings.py without branches vs base/dev/prod splits: which readability problem does the former solve?
  3. Why must staging have the SAME variable STRUCTURE as prod even if the values point to sandboxes?
  4. Explain dual secret rotation in settings and its window. What happens if you forget to remove *_OLD?
  5. 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.