Deployment
Premier League Predictor: Django & MySQL
Chapter 11 · Deployment
Every earlier chapter ran with python manage.py runserver — a genuinely fine development server, and a genuinely wrong choice for real production traffic.
Running With Multiple Workers
pl_predictor_site.wsgi:application — not main:app — is the real entry point Chapter 1's own django-admin startproject pl_predictor_site . already generated, sitting at pl_predictor_site/wsgi.py. Gunicorn manages several separate worker processes running that WSGI application — real request-handling capacity beyond what one process provides, with automatic restarts if a worker crashes.
fetch() call in its own JavaScript has always targeted a relative, same-origin path — /fixtures/${id}/predictions/upsert/, never a separate host. There's never been a second origin in the picture, the same real fact this site's own plpredict-fastapi1 and fastapi-food-tracker-1 courses both already established — just arrived at here via Django's own template rendering rather than FastAPI's StaticFiles mount.
Static Files: A Real New Requirement DEBUG=False Introduces
Every chapter since Chapter 4 has relied on Django's own development server quietly serving predictor/static/predictor/fixtures.js, style.css, and everything else — a genuine, documented convenience that only exists while DEBUG = True. The moment DEBUG is turned off for production, Django's dev-server-only static handling stops working entirely, and nothing at all is served at /static/... unless something else is configured to do it.
plpredict-fastapi1's own StaticFiles mount serves its frontend files identically in development and production — nothing changes at deployment time. Django's own dev-server static handling is explicitly, deliberately development-only; collectstatic gathers every app's own static files (including predictor/static/predictor/fixtures.js, auto-discovered by Django's own AppDirectoriesFinder with no extra config needed) into one real directory, STATIC_ROOT, and WhiteNoise's own middleware serves that directory efficiently straight from the WSGI app itself — no separate static file server required, though a reverse proxy serving STATIC_ROOT directly is an equally valid, slightly faster alternative for a busier deployment.
A Full-Circle Caveat on the Admin
Chapter 3's own tip-box named the Django admin as a genuine advantage this course earns largely for free — SeasonTeamInline alone replaced what would otherwise have been real, hand-written CRUD routes. Before a real deployment, that same feature deserves an honest second look: it's a real, working interface for editing every table in this app, reachable by anyone who finds the URL and guesses (or brute-forces) a password.
DEBUG = False stops Django's own real debug error pages — which, left on, expose settings, SQL queries, and full stack traces to any visitor who triggers a 500 — from ever being shown in production. ALLOWED_HOSTS is a second, separate real guard: with DEBUG = False, Django refuses any request whose Host header isn't in this list outright, with a real 400 and no view code ever running at all. Moving the admin off its default /admin/ path is genuinely only security-through-obscurity — a real deterrent against the automated scanners that specifically probe for the well-known default path, not a substitute for a strong password on every admin account, which still matters more.
MySQL Connections at Scale: Django's Own Default Is the Opposite Problem
plpredict-fastapi1's own PostgreSQL sibling has to cap down an aggressively pooling SQLAlchemy engine. Django's own MySQL backend starts from the completely opposite default:
CONN_MAX_AGE set, Django's own documented default is 0: every request opens a fresh MySQL connection at the start and closes it again at the end, regardless of which of the 4 gunicorn workers handles it. There's no connection-count explosion risk the way SQLAlchemy's own pool-per-worker-process default creates for the PostgreSQL sibling — the real cost here is paid on every single request instead, as a genuine TCP handshake plus a real MySQL authentication exchange, adding measurable latency to literally every page view under this app's own unmodified defaults.
CONN_MAX_AGE=60 is the standard fix: each worker process's own thread reuses the same real MySQL connection across requests for up to 60 idle seconds before Django closes and reopens it, instead of paying the connection cost on every single call. CONN_HEALTH_CHECKS=True — a real Django 4.1+ setting, safely available given this course's own MySQL 8+/Django 4.2+ baseline from Chapter 2 — pings a reused connection before handing it to a view, transparently reconnecting if MySQL's own wait_timeout already closed it server-side in the meantime, rather than surfacing a stale-connection error to a real user.
-w 4 --threads 2 for real request-handling concurrency means up to 4 × 2 = 8 genuine persistent MySQL connections held open at once with CONN_MAX_AGE=60 in place — not just 4, since worker count alone doesn't capture the real multiplier once threads are involved. Comfortably inside MySQL's own default max_connections of 151, with real headroom left for an admin session, a monitoring agent, or anything else connecting to the same database — but worth knowing the real formula (workers × threads) rather than assuming the worker count alone tells the whole story, the same discipline the PostgreSQL sibling's own worked calculation teaches from the opposite direction.
Chapter 1's Own Password Gap, Closed
Chapter 1's own DATABASES block shipped with 'PASSWORD': '' and an explicit comment — # set a real password in production — flagging exactly this moment. The block above finally makes good on it, pulling every credential from a real environment variable instead of a hardcoded value, with the empty string surviving only as a local-development fallback. SECRET_KEY needs the identical treatment:
SECRET_KEY — the random value startproject generates and writes straight into settings.py — signs session cookies and CSRF tokens, the exact CSRF mechanism Chapter 4 already leaned on directly. Deploying with the auto-generated development value still committed to version control means anyone who's ever seen this project's own repository history could forge a valid session or CSRF token against the live site. A real production SECRET_KEY, generated fresh and set only as an environment variable on the deployment host, is non-negotiable in a way that has no real analog in the FastAPI/PostgreSQL sibling's own deployment chapter at all.
Schema Changes After Deployment — Already Solved, With a Caution
plpredict-fastapi1's own SQLAlchemy schema is created with Base.metadata.create_all(bind=engine) — a one-shot table creator that can't alter a table that already exists, leaving that sibling course needing a genuinely separate tool (Alembic) it never actually builds. This course has never had that gap: python manage.py makemigrations/migrate has been the real, working schema-change tool since Chapter 2, and it's exactly as usable after deployment as it was in development.
makemigrations locally against a development database, reviewing the real migration file it generates, committing that file to version control, and only ever running migrate — never makemigrations itself — on the production server. Running makemigrations directly against live production data risks Django prompting for a default value on a genuinely ambiguous schema change (a new required field, say) interactively, in a context — a deployment script, a production shell — where there's no real developer present to answer that prompt correctly.
TLS Termination
Gunicorn itself doesn't handle TLS certificates — a reverse proxy (nginx, the same pattern this site's own Nginx In Depth course already covers) sits in front of it, terminating HTTPS and forwarding plain HTTP internally. The same reverse proxy is also the natural place to serve STATIC_ROOT directly, as a faster alternative to WhiteNoise, once traffic genuinely warrants it.
A Production Checklist
- Run with
gunicorn pl_predictor_site.wsgi:application -w 4, neverrunserver. - Run
collectstaticand serveSTATIC_ROOTvia WhiteNoise or a reverse proxy — Django's own dev server no longer serves static files at all onceDEBUG=False. - Set
DEBUG=False, a realALLOWED_HOSTS, and consider moving the admin off its default path. - Set
CONN_MAX_AGEandCONN_HEALTH_CHECKS=True, and size the real workers × threads connection count against MySQL's ownmax_connections. - Set a real
DATABASESpassword and a fresh, realSECRET_KEY— both as genuine environment variables, closing the exact gap Chapter 1's own comment flagged. - Run
makemigrationslocally and commit the result; only runmigrateitself against production. - Put a reverse proxy in front for TLS termination.
Where This Course Is Headed
One chapter left: a capstone tying every route and page built across this course into one complete, working predictor, integrated into the existing Astro-based site.
Hands-On Exercises
Explain why this course never needed CORS configuration anywhere, tracing the reason back to how templates and static files have been served since Chapter 4.
📄 View solutionExplain why Django's own default CONN_MAX_AGE=0 creates a genuinely different kind of problem than SQLAlchemy's own pooling defaults do for the PostgreSQL sibling, then calculate the real number of persistent MySQL connections a gunicorn -w 4 --threads 2 deployment can hold open at once with CONN_MAX_AGE=60 in place.
📄 View solutionExplain what would actually happen if this app were deployed with DEBUG left True and ALLOWED_HOSTS left empty — both the real information a visitor could see on a triggered error, and the real error a normal request would hit regardless.
📄 View solutionChapter 11 Quick Reference
- Run with: gunicorn pl_predictor_site.wsgi:application -w 4, never runserver in production
- No CORS anywhere: same real reason as the FastAPI sibling — one origin, the whole way through
- Static files: a real new requirement DEBUG=False introduces — collectstatic + WhiteNoise, since Django's dev server no longer serves them
- Full-circle caveat: the admin, Chapter 3's own praised feature, needs DEBUG=False, real ALLOWED_HOSTS, and ideally a non-default path
- Real connection math, the opposite problem: CONN_MAX_AGE=0 by default means a fresh connection per request; CONN_MAX_AGE=60 + 4 workers × 2 threads = up to 8 real persistent connections, against MySQL's own default max_connections of 151
- Chapter 1's own flagged gap, closed: real DATABASES credentials and a real SECRET_KEY, both from environment variables
- Schema changes: already solved by makemigrations/migrate, unlike the sibling's own create_all() gap — just never run makemigrations itself against production
- TLS: a reverse proxy in front, same pattern as this site's own Nginx In Depth course
- Next chapter: Capstone