Static files and media in production
"My CSS disappeared when I deployed" is one of the most common Django deployment surprises, and it comes
from a single fact: runserver serves static files, but production does not. The static-and-media
lesson introduced the split; this lesson is the production half — how static files and user uploads actually
get served when DEBUG=False, and why the development behaviour you relied on is deliberately gone.
Why static files "disappear" in production
In development, with DEBUG=True, Django's runserver finds and serves your CSS, JS and images
automatically. In production, with DEBUG=False, Django does not serve static files at all — by design.
Serving static files through Python is slow and wasteful; that job belongs to a web server or a dedicated
tool optimised for it. So the moment you set DEBUG=False and deploy, the automatic serving stops, and if
you did nothing else, your site loads with no styling. This is not a bug — it is Django refusing to do a job
it should not do in production. The fix is collectstatic plus something to serve the collected files.
collectstatic: gather everything into one place
Your static files live scattered across apps (patients/static/patients/…, plus admin's, plus DRF's).
Production needs them in one directory. Set STATIC_ROOT and run collectstatic:
# settings.py
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" # where collectstatic gathers everything
python manage.py collectstatic --noinput
Verified: this copies every static file — from your apps, the admin, and installed packages — into
STATIC_ROOT (in the reference run, 154 files collected). That single directory is then what gets
served. collectstatic is a build step: you run it as part of every deploy, after which the collected
files are ready to serve. Forgetting it is the number-one "CSS is missing in production" cause — the files
were never gathered.
Serving the collected files: WhiteNoise or a web server/CDN
Something has to serve STATIC_ROOT efficiently. Two common approaches:
WhiteNoise (simplest — serve from the app server). A small package that lets your WSGI/ASGI app serve static files efficiently, with compression and caching headers, no separate web server needed:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
# ...
]
STORAGES = {
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
WhiteNoise is ideal for small-to-medium sites and container/platform deploys (it needs no extra
infrastructure), and the CompressedManifestStaticFilesStorage adds compression and cache-busting hashed
filenames. For most projects — including a clinic app — WhiteNoise is the pragmatic, low-cost choice.
A web server or CDN (for larger scale). Nginx serving STATIC_ROOT directly, or a CDN/object store
(S3, etc.) holding the files. This offloads serving entirely from your app and is worth it at higher traffic
or for global reach — but it is more infrastructure to run, so reach for it when you actually need it, not by
default.
Media (user uploads) in production is different again
Do not conflate static and media (the static-and-media lesson's split matters most here). Static files are collected and served as above. Media — user uploads (a scanned report) — cannot be collected (they are created at runtime) and, crucially, should not sit on the app server's local disk in production:
- On many production platforms the filesystem is ephemeral — it is wiped on each deploy or restart, so local uploads vanish. Even where it persists, local storage does not scale across multiple app servers (each would have different files).
- The standard solution is an object store — S3, or a compatible service — via
django-storages, so uploads go to durable, shared, scalable storage independent of the app servers.
# media to S3 via django-storages
STORAGES = {
"default": {"BACKEND": "storages.backends.s3.S3Storage"}, # user uploads
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
And remember the security point: sensitive uploads must not be at a public, guessable URL. A patient's report served from a public bucket URL is a data leak; gate access behind a permission-checking view, or use signed, expiring URLs. Static files are public by nature (that is fine — they are your CSS); media may be private, and a clinic's media usually is.
The production static/media checklist
Bringing it together, before and during deploy:
- Set
STATIC_URLandSTATIC_ROOT. - Run
collectstaticas a deploy build step (verified: gathers all files intoSTATIC_ROOT). - Serve static files with WhiteNoise (simple) or a web server/CDN (scale).
- Send media to durable, shared storage (object store via
django-storages), never the ephemeral local disk. - Gate sensitive media behind permissions or signed URLs — do not serve private files from a public URL.
Get these five right and the "styling vanished on deploy" and "uploads disappeared after a restart" surprises never happen. They are the predictable failure modes of not understanding the development/ production split — and now you do.
Check your work
Why static files vanish in production. runserver serves them in development; with DEBUG=False
Django does not serve static files (by design — Python should not) — so without collectstatic + a
server, the site loads unstyled.
collectstatic. Gathers scattered static files (apps, admin, packages) into STATIC_ROOT as a deploy
build step (verified: 154 files collected); forgetting it is the top "missing CSS" cause.
Serving static. WhiteNoise (serve efficiently from the app server, compression + hashed filenames — simplest, low cost) or a web server/CDN (for scale, more infrastructure).
Media is different. User uploads cannot be collected and must not live on the ephemeral local disk (wiped
on deploy, not shared across servers) — use an object store via django-storages.
Sensitive media. Do not serve private uploads from a public/guessable URL — gate behind a permission view or signed URLs; static files are public by nature, a clinic's media usually is not.
Practice
- Set
DEBUG=Falseand load a page; confirm the styling is gone. AddSTATIC_ROOT, runcollectstatic, and set up WhiteNoise; confirm styling returns. - Run
collectstatic --noinputand inspectSTATIC_ROOT; note files from your apps, the admin, and packages all gathered there. - Add
CompressedManifestStaticFilesStorageand observe hashed filenames (cache-busting) in the collected output. - Reason about why storing user uploads on the local disk fails on a platform with an ephemeral filesystem.
- Configure media to an object store (or describe the
django-storagesS3 setup) and explain why it scales where local disk does not. - Design access control for a patient's uploaded report so it is not reachable at a public URL.
Official documentation
- Django — Deploying static files —
collectstatic,STATIC_ROOT, serving in production. - WhiteNoise documentation — Serving static files from the app server.
- django-storages — Sending media to object storage.
Next: containerising a Django app.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship