Long-Lived Caching for Django Static Files
Cache Django static files for a year without stale assets: manifest hashing, immutable Cache-Control headers in Nginx and WhiteNoise, CDNs, and deploy order.
Problem statement
Static files (CSS, JavaScript, fonts, images) make up most of the bytes on a typical page. Serving them with short or missing cache headers means browsers download or revalidate them on every visit, slowing page loads and increasing bandwidth. Serving them with long cache headers but unchanged filenames means users keep stale CSS and JavaScript after deploys, breaking layouts and causing JavaScript errors against new HTML.
The standard solution is content-based filenames: app.css becomes app.3f2a1b9c8d7e.css, and the name changes whenever the content changes. Files with such names can be cached "forever", because a changed file is a different URL.
Django supports this through ManifestStaticFilesStorage, and WhiteNoise builds on it. The details still trip teams up: which files are hashed, what happens to references inside CSS, what headers to send, and how deploys must be ordered.
Quick answer
- Use
django.contrib.staticfiles.storage.ManifestStaticFilesStorageorwhitenoise.storage.CompressedManifestStaticFilesStorageforSTORAGES["staticfiles"]. - Always reference static files through
{% static %}in templates andstatic()in Python, never hardcoded/static/...paths. - Run
collectstaticon every deploy; it writes hashed copies andstaticfiles.json. - Serve hashed files with
Cache-Control: public, max-age=31536000, immutable. - Keep unhashed or special files (for example
robots.txt,favicon.ico, service workers) on short cache lifetimes or outsideSTATIC_URL. - Deploy static files before (or together with) the code that references them, and keep previous hashed files available for a while.
- Verify headers and that changed files get new URLs.
Step-by-step solution
1. Enable manifest storage
Plain Django with Nginx serving files:
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
}
With WhiteNoise:
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
Run:
python manage.py collectstatic --noinput
ls staticfiles/css/
cat staticfiles/staticfiles.json | python3 -m json.tool | head -20
You should see both app.css and app.<hash>.css, and the manifest mapping original names to hashed names.
2. Reference files correctly
Templates:
{% load static %}
<link rel="stylesheet" href="{% static 'css/app.css' %}">
<script src="{% static 'js/app.js' %}" defer></script>
Python code (for example in emails or API responses):
from django.templatetags.static import static
logo_url = static("img/logo.png")
With manifest storage and DEBUG = False, these return hashed URLs. Hardcoded paths such as <img src="/static/img/logo.png"> bypass hashing and serve the unhashed copy, which must not be cached long-term.
References inside CSS (url("../img/bg.png")) are rewritten to hashed names during collectstatic. JavaScript import statements are not rewritten unless you enable module import aggregation support in newer Django versions; bundlers handle this better for complex JavaScript.
3. Set cache headers for hashed files
WhiteNoise sets long cache headers automatically for files it recognizes as hashed by the manifest, and shorter ones for other files. No extra configuration is needed for the common case.
Nginx serving STATIC_ROOT cannot tell hashed from unhashed files by location alone. Match hashed filenames with a regular expression:
location /static/ {
alias /srv/myapp/staticfiles/;
access_log off;
location ~* "\.[0-9a-f]{12}\.(css|js|png|jpg|jpeg|gif|svg|webp|woff2?|ico)$" {
add_header Cache-Control "public, max-age=31536000, immutable";
}
add_header Cache-Control "public, max-age=3600";
}
- Django's
ManifestStaticFilesStorageuses a 12-character hexadecimal hash in filenames. - The nested location matches only hashed filenames and marks them immutable for one year.
- Other files under
/static/get one hour.
Nested locations inside a location using alias inherit it. Test with nginx -t and requests to both hashed and unhashed names.
immutable tells supporting browsers not to revalidate the file even when the user reloads the page, eliminating conditional requests for assets that can never change at that URL.
4. Handle special files
Some files must keep stable URLs and should not be cached for long:
robots.txt,sitemap.xml,favicon.ico(browsers request fixed paths)manifest.webmanifestand service worker scripts (browsers check them for updates)- files referenced by external systems with fixed URLs (email templates sent months ago, partner embeds)
Serve them from dedicated locations or views with short cache lifetimes, not through a year-long rule.
5. Deploy order
When HTML from the new release references app.9b8c7d6e5f4a.css, that file must already be available:
- Run
collectstaticfor the new release (on the server, in the image build, or uploading to the CDN). - Switch application servers to the new release.
- Keep old hashed files for at least a day, or longer if pages are cached by a CDN or open in long-lived browser tabs.
collectstatic does not delete old hashed files unless you use --clear. On rolling deploys where each server has its own STATIC_ROOT, a request for a new asset can reach a server that still runs the old release. Mitigate by serving static files from a shared location or CDN populated before the rollout, or by baking assets into images so each server serves exactly its own version.
6. CDNs
With a CDN in front, the CDN respects origin Cache-Control headers (depending on its configuration). Hashed files can stay at the edge for a year, which removes nearly all static traffic from your servers. You rarely need to purge the CDN for static files; only unhashed files require purges or short lifetimes.
7. Common pitfalls
DEBUG = Truein staging: manifest storage is bypassed in debug mode, so staging does not exercise hashed URLs. Test with debug off.- Missing manifest entries: templates referencing files that do not exist raise
ValueErrorwithmanifest_strict = True(the default), which surfaces typos at runtime. Run a CI job that renders key templates with debug off. - Third-party apps with hardcoded paths: some packages build static URLs manually; check the rendered HTML for unhashed
/static/references. - Query-string cache busting (
app.css?v=3): easy to forget to bump, and some caches ignore query strings. Prefer content hashes.
8. Verify
# The rendered HTML references hashed files
curl -s https://www.example.com/ | grep -o '/static/[^"]*\.\(css\|js\)' | head -5
# Headers on a hashed file
curl -sI https://www.example.com/static/css/app.3f2a1b9c8d7e.css | grep -i -E "^HTTP|cache-control"
# Headers on an unhashed file
curl -sI https://www.example.com/static/css/app.css | grep -i cache-control
Then change a CSS rule, deploy, and confirm the referenced filename changed.
Verification check:
- rendered HTML contains only hashed static URLs
- hashed files return
Cache-Control: public, max-age=31536000, immutable - unhashed files have short cache lifetimes
- changing a file produces a new hashed URL after deploy
- no
404responses for static assets during and after a rolling deploy
Explanation
Browser and CDN caches are keyed by URL. Content-hashed filenames make the URL a fingerprint of the content, so a cached copy at that URL is always correct and can be kept indefinitely. Django's manifest storage produces those names and a manifest that {% static %} uses to render them, while long-lived immutable headers let clients skip network requests entirely. The remaining work is operational: making sure new files exist before HTML references them, and keeping old ones around for pages that still point to them.
Edge cases / notes
Frontend bundlers
If Vite, webpack, or esbuild already outputs hashed filenames, you can collect those files as-is. Avoid double hashing by excluding already-hashed bundle directories from manifest processing, or serve bundler output from its own location with the same long cache headers.
Source maps
Source maps referenced by hashed JavaScript must exist, or collectstatic fails during post-processing. Decide whether to publish them.
Private static files
Static files are public by nature. Anything that requires authorization belongs in media or a protected view, not STATIC_ROOT.
Testing hashed URLs in CI
Add a test that renders a page with production static storage and asserts hashed URLs appear:
import re
from django.core.management import call_command
from django.test import TestCase, override_settings
@override_settings(
DEBUG=False,
ALLOWED_HOSTS=["testserver"],
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
},
)
class HashedStaticTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
call_command("collectstatic", interactive=False, verbosity=0)
def test_home_page_uses_hashed_css(self):
html = self.client.get("/").content.decode()
self.assertRegex(html, r'/static/css/app\.[0-9a-f]{12}\.css')
self.assertIsNone(re.search(r'/static/css/app\.css"', html))
Point STATIC_ROOT at a temporary directory in test settings. The test fails when a template hardcodes an unhashed path or when manifest storage is accidentally replaced.
Cleaning up old hashed files
Keeping every old hashed file forever makes STATIC_ROOT grow with each release. A periodic cleanup can remove files that are no longer referenced by recent manifests:
- Keep copies of
staticfiles.jsonfrom the last few releases (for example in release directories). - Collect the set of hashed names referenced by those manifests.
- Delete hashed files in
STATIC_ROOTolder than your retention period that are not in the set.
Run cleanup well after deploys, and never delete files referenced by any release that could still serve traffic or be rolled back to.
Common mistakes
- Setting year-long cache headers on unhashed filenames.
- Hardcoding
/static/...paths in templates. - Running
collectstatic --clearon live servers during deploys. - Testing static handling only with
DEBUG = True.
Next steps
- Serve hashed files from Django with How to Serve Django Static Files with WhiteNoise.
- Push them to a CDN with Serve Django Static Files Through CloudFront.
- Resolve manifest build failures with Fix collectstatic Errors in Django Deployments.
- Shrink them further with Enable Gzip and Brotli Compression for Django with Nginx.
FAQ
How do I cache Django static files for a long time safely?
Use ManifestStaticFilesStorage (or WhiteNoise's compressed manifest storage) so filenames include content hashes, reference files with {% static %}, and serve hashed files with a one-year immutable cache header.
Why do users see old CSS after I deploy Django?
The file probably has an unhashed name with a long cache lifetime, or is referenced by a hardcoded path. Content-hashed filenames make changed files load from new URLs.
Does WhiteNoise set cache headers automatically?
Yes. WhiteNoise serves files it identifies as hashed with long cache lifetimes and other static files with shorter ones.
Do I need to purge my CDN after deploying static files?
Not for hashed files, because changed content gets a new URL. Purge or use short lifetimes only for files with fixed names.