Production-ready cache-busting for Django and Tailwind CSS

I’m a big fan of the django-tailwind-cli package. It makes integrating Tailwind CSS into a Django project incredibly simple. By managing the Tailwind watcher process for you, it streamlines development, especially when paired with django-browser-reload for live updates. It’s a fantastic developer experience.

However, when I first deployed a project using this setup, I ran into a classic problem: caching. You see, django-tailwind-cli creates a single tailwind.css file that you load in your base template. In production, browsers and CDNs will aggressively cache this file to improve performance. This is normally a good thing! But when you deploy an update, like adding a new Tailwind class to a template, your users might not see the changes. Their browser will continue to serve the old, cached tailwind.css file, leading to broken or outdated styling.

Luckily, Django has a built-in cache-busting mechanism in the form of ManifestStaticFilesStorage. But, there’s one important caveat: you can’t use this class directly. The Tailwind build process relies on a source file (typically css/source.css) that contains this line:

@import "tailwindcss";

When collectstatic runs, ManifestStaticFilesStorage tries to be helpful and process this file, too. It attempts to find and hash source.css, and it also attempts to hash the imported tailwindcss, which won’t work.

The solution is to create a custom storage class that tells Django to leave source.css alone.

storage.py

from django.contrib.staticfiles.storage import ManifestStaticFilesStorage

class CustomManifestStaticFilesStorage(ManifestStaticFilesStorage):
    def hashed_name(self, name, content=None, filename=None):
        # Skip hashing for source.css — it's only used during Tailwind compilation
        if name == 'css/source.css':
            return name
        return super().hashed_name(name, content, filename)

    def post_process(self, paths, **options):
        # Exclude source.css from post-processing
        paths = {k: v for k, v in paths.items() if k != 'css/source.css'}
        return super().post_process(paths, **options)

Then configure it in settings.py:

settings.py

STATIC_ROOT = BASE_DIR / "static_root"
STATIC_URL = "/static/"

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
        if DEBUG else "storage.CustomManifestStaticFilesStorage",
    },
}

The last thing to do is update your base template. Replace the {% tailwind_css %} tag with:

base.html

<link rel="preload" href="{% static 'css/tailwind.css' %}" as="style">
<link href="{% static 'css/tailwind.css' %}" rel="stylesheet" />

With everything configured, your deployment process for static files will now be a two-step command:

./manage.py tailwind build
./manage.py collectstatic --noinput

First, tailwind build creates the final tailwind.css file. Then, collectstatic picks it up, hashes it with a unique name like tailwind.4e3e58f1a4a4.css, and places it in your STATIC_ROOT directory, ready to be served.

That’s it! Your Tailwind styles are now production-ready and properly cache-busted.

Written by

Kevin Renskers

Freelance software developer with over 25 years of experience. Writes articles about Swift, Python, and TypeScript. Builds Critical Notes, and maintains a bunch of open source projects. Connect on Mastodon or Bluesky.

Related articles

› See all articles