Static files in production — collectstatic, STATIC_ROOT, and why runserver won't cut it
The dev server serving static files automatically is a convenience that quietly stops existing the moment DEBUG=False — collectstatic and a real static-file server are what replace it, and skipping this step is one of the most common first-deploy breakages.
3 min read
Why this breaks on first deploy, specifically
# settings.py
DEBUG = False # productionWith DEBUG = True, Django's development server serves static files (CSS, JS, images) automatically, straight from each app's static/ directory — genuinely convenient, and genuinely not something Django does in production. The moment DEBUG = False, that automatic serving stops entirely, on purpose (per Django's own docs: runserver serving static files is explicitly a development-only convenience, never intended for production traffic) — a project that never accounted for this deploys with every CSS/JS/image link returning 404, while every actual page still loads fine, which is exactly confusing enough to eat real debugging time on a first deploy.
STATICFILES_DIRS vs. STATIC_ROOT: two different jobs
# settings.py
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"] # SOURCE — extra dirs Django looks for static files in during development
STATIC_ROOT = BASE_DIR / "staticfiles" # DESTINATION — where collectstatic gathers everything, for productionSTATICFILES_DIRS tells Django where to find static files beyond each app's own static/ folder — this is a development-time discovery setting. STATIC_ROOT is a completely different thing: it's the single destination directory that collectstatic (below) copies every static file into, gathered from every app and every STATICFILES_DIRS entry, ready to be served by a real web server. Confusing the two is common — STATICFILES_DIRS is about finding files, STATIC_ROOT is about collecting them into one place.
collectstatic: gathering everything into one place
python manage.py collectstatic --noinputcollectstatic walks every installed app's static/ directory plus everything in STATICFILES_DIRS, and copies it all into STATIC_ROOT — one flat, deployable directory. This is a required step in almost every production deploy pipeline (often run automatically as part of a build/deploy script), and it's specifically what a project that only ever ran runserver locally is missing the first time it deploys with DEBUG = False.
Who actually serves the collected files
Options, roughly in order of how a small-to-medium project typically starts:
1. WhiteNoise — Django serves its own static files, even in production,
via middleware; no separate server/CDN needed to start.
2. nginx — a real web server serves STATIC_ROOT directly, in front
of the Django app server (gunicorn/uwsgi).
3. A CDN/object store (S3, Cloudflare R2) — collectstatic's destination
is a cloud storage backend instead of a local directory,
and files are served from there directly.
Django's own app server (gunicorn, uwsgi) is deliberately not optimized for serving static files efficiently at scale — that's not what it's for. WhiteNoise is the simplest fix for a small-to-medium deploy: it's a piece of middleware that lets the Django process itself serve the collected static files reasonably efficiently, with proper caching headers, without needing to configure a separate web server just for static assets. Larger deployments typically hand this off to nginx or a CDN/object-storage backend instead, for real edge caching and to keep static traffic off the application server entirely.
ManifestStaticFilesStorage: cache-busting for free
# settings.py
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
}Without cache-busting, a browser (or CDN) that already cached style.css keeps serving the old version after a deploy, since the filename never changed — a real, common "why isn't my CSS update showing up in production" bug. ManifestStaticFilesStorage renames each collected file to include a content hash (style.a3f5c9.css) and rewrites every reference to it accordingly — a changed file gets a new filename, which is guaranteed to bypass any existing cache, while an unchanged file keeps its old hashed name and stays cached exactly as intended.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why do static files (CSS/JS) suddenly 404 in production after working fine locally, with no other code changes?
2. What's the difference between STATICFILES_DIRS and STATIC_ROOT?
3. What does python manage.py collectstatic actually do?
4. What problem does ManifestStaticFilesStorage solve?