Exercise 1 — Audit
Typical findings in a Django project arriving here: ALLOWED_HOSTS = ["localhost"] nailed down, EMAIL_HOST as a literal in settings, the payment gateway URL inside the service (requests.post("https://api.pay-gw.com/charges",...)) and the webhook secret in a fixture committed last month. Classification: gateway and SMTP URLs = bad config (move to the environment); global DEBUG=True = dangerous bad config; the if settings.DEBUG: in views = config leaking into the domain layer (the service must NOT read settings: it receives values by parameter — 24's inversion).
The final grep:
git grep -n "https://" -- '*.py' | grep -v test | grep -v "# doc"
# expected: only docstrings and the DTO documenting the APIThe preventive rule: a URL that isn't your API's is config; if a service needs it, it enters by parameter (24's injection) and its value comes from settings.
Exercise 2 — Complete settings.py
# settings.py
import environ, os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(
DJANGO_DEBUG=(bool, False),
DJANGO_SECRET_KEY=(str, ""),
ALLOWED_HOSTS=(list, []),
DATABASE_URL=(str, ""),
REDIS_URL=(str, ""),
CELERY_BROKER_URL=(str, ""),
PAYMENT_GATEWAY_URL=(str, "https://sandbox.gw.example"),
PAYMENT_GATEWAY_KEY=(str, ""),
WEBHOOK_SECRET=(str, ""),
FEATURE_WEBHOOKS=(bool, False),
)
environ.Env.read_env(BASE_DIR / ".env") # dev: no-op in prod (the file doesn't exist)
SECRET_KEY = env("DJANGO_SECRET_KEY")
DEBUG = env("DJANGO_DEBUG")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS")
DATABASES = {"default": env.db("DATABASE_URL") if env("DATABASE_URL") else {
"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3" # CI only, no service
}}
CACHES = {"default": env.cache("REDIS_URL")} if env("REDIS_URL") else {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}.env.example:
DJANGO_DEBUG=false
DJANGO_SECRET_KEY= # generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
ALLOWED_HOSTS=localhost,127.0.0.1
DATABASE_URL=postgres://user:pass@localhost:5432/ticketflow
REDIS_URL=redis://localhost:6379/0
PAYMENT_GATEWAY_URL=https://sandbox.gw.example
PAYMENT_GATEWAY_KEY=
WEBHOOK_SECRET=
FEATURE_WEBHOOKS=falseThe real .env is ignored (git check-ignore.env confirms it) and staging/prod secrets live in the orchestrator's manager (42/43), not in files. The same-image proof: a single docker build → the sha256:... digest identical across the 3 docker run with different --env-file. If the hash changes, the Dockerfile leaks config.
Exercise 3 — Dual rotation
SECRET_KEYS = [k for k in (env("DJANGO_SECRET_KEY"), env("DJANGO_SECRET_KEY_OLD")) if k]
# in signature verification (17's HMAC or tokens):
def verify(data: bytes, signature: str) -> bool:
return any(hmac.compare_digest(compute(data, k), signature) for k in SECRET_KEYS)Rotation checklist (the window between 2 and 4 = the maximum lifetime of anything signed with the old key):
- Generate the new key, do NOT deploy yet.
- Deploy with
DJANGO_SECRET_KEY=new+DJANGO_SECRET_KEY_OLD=old(dual). - Watch signature-failure metrics (46): should be ~0 after the deploy.
- Wait ≥ 2× the maximum TTL of what is signed (18's 10-min access token → 20 min is enough).
- Deploy removing
*_OLD. If something signed with the old one after step 4, it was unmanaged traffic: investigate, don't rotate.
Tokens issued with the old key stay valid during the window (by design); afterwards they die — that is why the access token is short (18) and the refresh re-issues with the new one.
Exercise 4 — Fail-fast
if not DEBUG:
if not SECRET_KEY:
raise ImproperlyConfigured(
"DJANGO_SECRET_KEY is not set. Generate one with "
"`python -c \"import secrets; print(secrets.token_urlsafe(64))\"` and put it in the .env."
)
if not ALLOWED_HOSTS:
raise ImproperlyConfigured(
"empty ALLOWED_HOSTS outside DEBUG: Django would reject every request "
"(Host header). Add your domain, comma-separated."
)The smoke test with the environment overridden:
class SmokeSettings(TestCase):
def test_prod_fake_fails_without_secret(self):
with mock.patch.dict(os.environ, {"DJANGO_DEBUG": "false", "DJANGO_SECRET_KEY": ""}):
with self.assertRaises(ImproperlyConfigured):
importlib.reload(settings_module)
def test_prod_fake_with_full_config_passes_check_deploy(self):
...vars set...
call_command("check", "--deploy")The triage README: the 3 that are always missing are DJANGO_SECRET_KEY (500 at boot with the exact message), DATABASE_URL (the connection error arrives on the first request) and REDIS_URL (silently missing cache: the system runs SLOW, not broken — the worst failure). Each with its symptom and a two-line fix.
Exercise 5 — Config feature flags
# settings.py
FEATURE_WEBHOOKS = env.bool("FEATURE_WEBHOOKS", default=False)
FEATURE_SAGA_V2 = env.bool("FEATURE_SAGA_V2", default=False)
# service:
if settings.FEATURE_WEBHOOKS:
outbox.publish(ReservationConfirmed(...))The test runs the same suite with the flag on and off (override_settings(FEATURE_WEBHOOKS=True/False)): both paths stay alive. Versus per-environment branches: branches diverge and the merge is war; flags coexist on trunk and activate by deployment. 41 will use them to decouple deploy from release.
Professor's summary
- Config lives in the environment; safe defaults in code; one image, environments are variables.
- A flat settings.py without branches: every key read once, types parsed, readable failures.
- Fail-fast at boot + check --deploy in the pipeline: broken config is detected in minutes, not by the customer's complaint.