This episode tackles a common production problem: static and media files never reach the browser, or arrive stale. Following episode 4 on PostgreSQL, we finish the remaining data layer: fast-served static files and durable media. By the end of this episode: collectstatic runs once and just works, the browser caches correctly, and uploaded images no longer live inside the deploy.
1. Serving static files with WhiteNoise
In dev, runserver serves static files for you, but Gunicorn in production does not do that job. WhiteNoise is middleware that serves static files directly from the application, with gzip compression and cache headers — good enough for small and medium apps. Add it right after SecurityMiddleware and enable hashed static filenames so browsers do not keep old versions.
# settings.pyMIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", # ... other middleware]STORAGES = { "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", },}
2. collectstatic and STATIC_ROOT
STATIC_URL is the path the browser requests, while STATIC_ROOT is the real folder on disk where collectstatic gathers every static file into one place. Run collectstatic during the deploy step, before restarting Gunicorn. If you later use Nginx as described in episode 3, Nginx points at this very folder, so switching costs no code changes.
python manage.py collectstatic --noinputls staticfiles/admin/css/ | head
3. Cache headers: hash filenames, do not cache everything
When a filename contains a content hash, a content change changes the name, so a one-year cache is still safe: the browser keeps the old name’s copy and fetches the new name when a new version appears. Unhashed files like the favicon get only a short cache, and never apply long caching to HTML or APIs. Verify with curl -I to see the actual Cache-Control header.
# WhiteNoise: 1-year cache for hashed static filesWHITENOISE_MAX_AGE = 60 * 60 * 24 * 365
4. Media on S3 or MinIO with django-storages
Media is created by users at runtime, so do not commit it to git and do not keep it on the container disk — a container restart wipes it. The fix is object storage: Amazon S3 if you use the cloud, or self-hosted MinIO. The django-storages package provides the S3Storage backend; just declare it, put the keys in environment variables as episode 2 advised, then test-upload one image through the admin page.
# settings.pySTORAGES = { "default": {"BACKEND": "storages.backends.s3.S3Storage"}, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", },}AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")# None for real S3, MinIO URL when self-hostingAWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", default=None)
5. Checks before calling it done
- Run collectstatic on staging and confirm it reports no missing files.
- Use curl -I to check the Cache-Control header of one CSS file and one media image.
- Upload a test image, redeploy the app, then open that image again to be sure the media did not disappear with the old container.
- Delete the test image and note the bucket in the runbook.
Apply it to your project
For the expense-tracker app, enable WhiteNoise now to end the missing-CSS-after-every-deploy bug, and move media to S3 or MinIO once real users start uploading receipt photos. Do not wait until the server disk fills up to migrate — moving old data in a hurry is how files get lost.
Related posts
- Django Production Operations #4: PostgreSQL in Production
- Django Production Operations #3: Gunicorn + Nginx
- Django Production Operations #2: Split Dev/Staging/Prod Settings
Frequently asked questions
Why does production lose its CSS while dev works fine?
Because runserver serves static files itself while Gunicorn does not. Enable WhiteNoise or let Nginx serve the STATIC_ROOT folder and the problem goes away.
Can WhiteNoise replace Nginx?
For small and medium apps, yes; under heavy traffic, switch to Nginx or a CDN.
Should media share a folder with static files?
No. Static files belong to the code; media must live on object storage or a separate durable disk, detached from the deploy lifecycle.
I changed the CSS but the browser still shows the old version — how do I fix it?
Use hashed static filenames and check the Cache-Control header; if it is still old, clear the browser cache before suspecting the CDN.
💬 Have you tried this in your own project? If you hit an error or know a cleaner way, leave a comment below — I read them all and will fold the best ideas into the post.
