WhiteNoise and a CDN are your best friends for serving static files in Django production, but they’re not magic. You still need to understand how they work together to avoid common pitfalls.

Here’s a Django app with a few static files:

# myapp/views.py
from django.shortcuts import render

def index(request):
    return render(request, 'myapp/index.html')

# myapp/urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('', views.index, name='index'),
]

# myapp/templates/myapp/index.html

{% load static %}

<!DOCTYPE html>
<html>
<head>
    <title>Static Files Test</title>

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

</head>
<body>
    <h1>Hello, Static Files!</h1>

    <img src="{% static 'myapp/image.png' %}" alt="A static image">

</body>
</html>

# myapp/static/myapp/style.css
body {
    background-color: lightblue;
}

# myapp/static/myapp/image.png
# (Assume a small PNG file here)

And the project’s settings.py:

# myproject/settings.py
import os

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

SECRET_KEY = '...'

DEBUG = False # Crucial for production settings

ALLOWED_HOSTS = ['yourdomain.com', 'www.yourdomain.com']

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'myapp', # Your app
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'myproject.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'myproject.wsgi.application'

DATABASES = { ... }

AUTH_PASSWORD_VALIDATORS = [ ... ]

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_L10N = True

USE_TZ = True

STATIC_URL = '/static/' # This is what WhiteNoise will use

STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') # Where collectstatic will put files

# WhiteNoise settings
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
WHITEНОISE_STATIC_PREFIX = '/static/' # Must match STATIC_URL

# CDN settings (example with Cloudflare, but applies to any CDN)
# These are usually set in your CDN provider's dashboard, not in Django settings directly.
# The concept is that your CDN pulls from your STATIC_ROOT.
# For local testing, you might use a simple web server pointed at STATIC_ROOT.
# For production, you'll configure your CDN to point to your production server's static URL.

When you run python manage.py collectstatic, Django finds all your static files (from myapp/static/ and any other static/ directories in your apps) and copies them to STATIC_ROOT (yourproject/staticfiles/). WhiteNoise, configured in settings.py, then intercepts requests for /static/ and serves these files directly from disk.

The most surprising true thing about serving static files with WhiteNoise and a CDN is that for many common scenarios, WhiteNoise alone is sufficient and often faster than a misconfigured CDN.

Let’s see what happens when you deploy this. You’ve run python manage.py collectstatic and your files are in staticfiles/. Your web server (like Gunicorn) is running your Django app.

When a browser requests /static/myapp/style.css, Django’s URL resolver doesn’t match it. However, WhiteNoise’s middleware is placed after SecurityMiddleware and before SessionMiddleware. It inspects the request path. If it starts with WHITEНОISE_STATIC_PREFIX (which we’ve set to /static/), WhiteNoise takes over. It looks for myapp/style.css within the directories specified by STATICFILES_DIRS (if any) and, more importantly for production, within STATIC_ROOT.

If the file exists in STATIC_ROOT/myapp/style.css, WhiteNoise serves it directly. It’s highly optimized for this: it can compress files on the fly (if configured), set appropriate cache headers, and handle conditional requests (If-Modified-Since, ETag).

To integrate a CDN, you typically configure your CDN provider (e.g., Cloudflare, AWS CloudFront, Akamai) to point to your production server’s static URL. When a user requests https://yourdomain.com/static/myapp/style.css, the request might first hit the CDN. If the CDN has a cached copy, it serves it. If not, the CDN fetches it from your origin server (your Django app served by Gunicorn/Nginx). WhiteNoise is still the component serving the file to the CDN. The CDN then caches it for future requests.

The key here is that STATIC_URL should typically be /static/ and WHITEНОISE_STATIC_PREFIX should match it. STATIC_ROOT is where collectstatic gathers everything.

You’ll often see people struggle with cache-busting. WhiteNoise handles this automatically if you enable its compression and ManifestStaticFilesStorage (though WhiteNoise’s built-in CompressedManifestStaticFilesStorage is preferred). When enabled, collectstatic will rename files to include a hash of their content, like style.a1b2c3d4e5f6.css. Django’s {% static %} tag will then correctly generate URLs with these hashed filenames. The CDN will then cache these versioned files correctly.

Here’s a common setup for WhiteNoise with compression and caching:

# settings.py
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'whitenoise.middleware.WhiteNoiseMiddleware', # Place it after SecurityMiddleware
    'django.contrib.sessions.middleware.SessionMiddleware',
    # ... other middleware
]

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
WHITEНОISE_COMPRESSION_LEVEL = 9 # Max compression
WHITEНОISE_CACHE_MAX_AGE = 60 * 60 * 24 * 365 # Cache for 1 year

# If using multiple static file finders, you might need this:
# STATICFILES_FINDERS = [
#     'django.contrib.staticfiles.finders.FileSystemFinder',
#     'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# ]

Running python manage.py collectstatic with CompressedManifestStaticFilesStorage will now create files like myapp/style.a1b2c3d4e5f6.css in STATIC_ROOT. WhiteNoise will serve these with aggressive caching headers. Your CDN will then cache these versioned URLs.

The one thing most people don’t know is that WhiteNoise’s CompressedManifestStaticFilesStorage automatically handles both compression (gzip/brotli) and manifest generation (cache-busting) in a single storage class, simplifying your settings.py significantly compared to trying to combine Django’s ManifestStaticFilesStorage with separate compression tools. It intelligently serves the correct compressed version based on the browser’s Accept-Encoding header and ensures correct cache invalidation via the manifest hash.

If you’ve correctly configured WhiteNoise and your CDN to pull from STATIC_ROOT, but your CSS and JS are still broken, the next error you’ll likely hit is a 404 Not Found for /media/ files if you’re using Django’s MEDIA_URL and haven’t configured a separate handler for user-uploaded content.

Want structured learning?

Take the full Django course →