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.

Safe long-lived cachingBuildcollectstatic hashesManifestname to hashTemplatehashed URLCDNcached for a yearA content hash in the filename makes a new deploy a new URL.

Quick answer

  1. Use django.contrib.staticfiles.storage.ManifestStaticFilesStorage or whitenoise.storage.CompressedManifestStaticFilesStorage for STORAGES["staticfiles"].
  2. Always reference static files through {% static %} in templates and static() in Python, never hardcoded /static/... paths.
  3. Run collectstatic on every deploy; it writes hashed copies and staticfiles.json.
  4. Serve hashed files with Cache-Control: public, max-age=31536000, immutable.
  5. Keep unhashed or special files (for example robots.txt, favicon.ico, service workers) on short cache lifetimes or outside STATIC_URL.
  6. Deploy static files before (or together with) the code that references them, and keep previous hashed files available for a while.
  7. 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"},
}
the same tag, a hashed URL{% static"css/app.css" %}→ /static/css/app.3f9c2e1a7b04.cssstaticfiles.json: "css/app.css": "css/app.3f9c2e1a7b04.css"1231Templates never hard-code /static/ paths.2Hash of the file content: changes when it changes.3The manifest; a missing entry raises ValueError.Hashed names are what make year-long caching safedjango-deployment.com
CSS url() references are rewritten to hashed names too.

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.

cache headers by file typeCache-Controlapp.3f9c2e1a.css✓public, max-age=31536000, immutablefavicon.ico, robots.txt!max-age=86400HTML pagesno-cacheOnly files whose names change with their content get the long lifetimedjango-deployment.com
WhiteNoise applies these rules automatically for hashed files.

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 ManifestStaticFilesStorage uses 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.webmanifest and 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:

  1. Run collectstatic for the new release (on the server, in the image build, or uploading to the CDN).
  2. Switch application servers to the new release.
  3. 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.
static files first, code secondcollectstaticnew hashes addedOld files keptold pages still workRestart appnew HTML, new hashesCached pages expireold hashes unusedPrune old filesa release laterDeleting old hashed files at deploy time breaks cached pagesdjango-deployment.com
Browsers and CDNs may hold old HTML for minutes after the deploy.

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 = True in 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 ValueError with manifest_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 404 responses 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:

  1. Keep copies of staticfiles.json from the last few releases (for example in release directories).
  2. Collect the set of hashed names referenced by those manifests.
  3. Delete hashed files in STATIC_ROOT older than your retention period that are not in the set.
when to delete old hashed filescollectstatic --clear every deploy✕old hashes gone instantly✕cached pages load broken CSS✕rollback has no assetsKeep the last few releases✓old and new hashes coexist✓rollback finds its files✓prune by age on a scheduleDisk is cheap; a broken page after deploy is notdjango-deployment.com
Per-release static directories make pruning a simple directory delete.

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 --clear on live servers during deploys.
  • Testing static handling only with DEBUG = True.

Next steps

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.

Keep going