Guides / Django static files not loading in production

Django static files not loading in production

Your site looked fine locally. In production the admin is a wall of unstyled text, your CSS and JavaScript return 404, and Django static files not loading in production is the only thing you can think to search. The cause is almost always the same: Django serves static files on its own only while DEBUG = True, so in production something else has to do it.

What it means

python manage.py runserver serves /static/ for you in development, straight from your apps' static folders. With DEBUG = False that stops, by design. The staticfiles app instead expects a two-step setup: collectstatic copies every static file into one folder (STATIC_ROOT), and a web server, a CDN or middleware serves that folder (deploying static files).

Why it happens

Check these in order. To see which applies, open your browser's network tab and look at the status of one CSS file, or request it directly: curl -I https://example.com/static/admin/css/base.css.

  1. Nothing serves /static/. You return 404 for every file. This is the usual case after moving from runserver to gunicorn or uvicorn.
  2. STATIC_ROOT is not set, or collectstatic never ran, so the folder that should be served is empty or missing. Often it ran on your machine but not in the build for the server or container.
  3. Middleware is in the wrong place. WhiteNoise is installed, but not directly after SecurityMiddleware.
  4. STATIC_URL and the server disagree. The pages ask for /static/x.css while the server serves /assets/, or STATIC_URL is missing its leading or trailing slash.
  5. The folder is not in the deployed image. A .dockerignore or .gitignore excludes it, or collectstatic ran in a build stage that was not copied to the final image.
  6. The files are cached. The CSS is served, but an old copy comes from the browser or a CDN.

The fix

The simplest setup that works anywhere is WhiteNoise: Django serves its own static files, with compression and long-lived caching. It works with gunicorn, uvicorn and any host, and needs no nginx.

1. Install WhiteNoise

pip install whitenoise
# and add "whitenoise" to requirements.txt, or run: uv add whitenoise

2. Configure the settings (Django 4.2 and newer)

# settings.py
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

INSTALLED_APPS = [
    # ...
    "django.contrib.staticfiles",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # directly after SecurityMiddleware
    # ... the rest of your middleware
]

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

Notes on the version differences: STORAGES was added in Django 4.2. The older STATICFILES_STORAGE setting is deprecated since 4.2 and removed in Django 5.1, so use STORAGES on every version you run today. Keep the "default" entry: from Django 5.1 on, a STORAGES setting without it fails with InvalidStorageError the first time anything uses the default file storage (Django 4.2 and 5.0 filled it in). Add your own STATICFILES_DIRS if you keep project-level static files outside an app.

3. Run collectstatic on every deploy

python manage.py collectstatic --noinput

Run it as part of the build, after the code is in place and before the server starts, so that STATIC_ROOT is filled in the image that actually runs. In a Dockerfile:

COPY . /app
RUN SECRET_KEY=build-only python manage.py collectstatic --noinput

The throwaway SECRET_KEY keeps settings that insist on a secret from failing during the build. It exists only in that layer. If your settings need other variables just to import, set them the same way.

4. Prefer nginx or a CDN? Same two steps, different server

Keep STATIC_ROOT and collectstatic, skip the WhiteNoise lines, and let the web server serve the folder. The alias path must be the exact STATIC_ROOT and the location must match STATIC_URL:

location /static/ {
    alias /srv/myproject/staticfiles/;
    expires 30d;
    access_log off;
}

For a CDN or object storage such as S3, point STATIC_URL at its address and use a storage backend like django-storages. collectstatic then uploads instead of copying.

Making it stick in production

If styling works but pages return a 400 for your domain, see DisallowedHost: Invalid HTTP_HOST header. If forms fail on HTTPS, see CSRF verification failed. Request aborted.

Questions

Do I need nginx to serve static files?

No. WhiteNoise lets your Django process serve them efficiently, with caching headers and compression, and it works behind any host. nginx or a CDN in front helps at high traffic but is not required.

Or skip it

Django Cloud runs collectstatic during every build and serves the result with WhiteNoise, unless your settings already serve static files another way (WhiteNoise, a CDN or S3), so a deploy doesn't come up unstyled.

pip install djangocloud-cli
djangocloud deploy

Get startedSee pricing

Free plans are available for students, AWS Activate startups, open-source, non-profit and research projects. See how Django Cloud compares with other ways to host Django.

Django is a registered trademark of the Django Software Foundation. Django Cloud is not affiliated with or endorsed by it. Found a mistake in this guide? Tell us and we will fix it.