Fix Django Admin CSS Missing in Production

Fix an unstyled Django admin in production: collectstatic, STATIC_URL and alias paths, WhiteNoise order, MIME types, subpath deployments, CSP, and mixed content.

Problem statement

The Django admin works in production, but it looks like plain HTML: no blue header, no styled forms, list filters stacked in a column. Logging in and editing still work, so the problem is purely that the admin's CSS and JavaScript are not reaching the browser.

The admin's assets are ordinary static files shipped inside django.contrib.admin. The admin is often the first place people notice static file problems, because it is the one part of the site whose styling they did not build themselves. The causes range from "static files were never collected" to less obvious issues such as wrong MIME types, a Content Security Policy blocking styles, or a deployment under a URL prefix.

How admin styling is deliveredAdmin template{% static %}Manifesthashed nameSTATIC_URLfull URLServer or CDNserves the fileAn unstyled admin means one link in this chain is broken; the network tab shows which.

Quick answer

  1. Open the browser developer tools on an admin page and check the Network tab for admin/css/base.css: note the URL, status code, and Content-Type.
  2. 404: run collectstatic, check STATIC_ROOT, and make sure the web server or WhiteNoise serves that directory at STATIC_URL.
  3. 403: fix filesystem permissions on the static directory for the web server user.
  4. 200 but styles not applied: check Content-Type (must be text/css), Content Security Policy errors in the console, and mixed content warnings.
  5. Wrong URL (for example missing a prefix): set STATIC_URL to the public path, including any subpath the app is mounted under.
  6. Clear the browser cache or use a private window after fixing, then verify with curl -I.

Step-by-step solution

1. Look at the actual request

In the browser, open /admin/, open developer tools, reload, and filter the Network tab by css. Or use curl on the rendered page:

curl -s https://www.example.com/admin/login/ | grep -o 'href="[^"]*base[^"]*\.css"'
the status code tells you where to lookbase.css responseCause404not collected, or Nginx alias wrong403Nginx cannot read STATIC_ROOT200 as text/plainMIME types missing, nosniff blocks it200 but blockedCSP or mixed contentOpen base.css in the Network tab before changing anythingdjango-deployment.com
curl -I on the CSS URL shows the status and Content-Type at once.

You will see something like href="/static/admin/css/base.css" or, with manifest storage, href="/static/admin/css/base.1f3c9a2b7e4d.css". Request that exact URL:

curl -sI https://www.example.com/static/admin/css/base.css

The status code and headers decide which step applies.

2. Status 404: files were not collected or not served

Check that the file exists where the web server expects it:

python manage.py findstatic admin/css/base.css
ls -l /srv/myapp/staticfiles/admin/css/base.css
from URL to file/static/admin/css/base.cssSTATIC_URL + app pathNginx location /static/alias to STATIC_ROOT/srv/myapp/staticfiles/filled by collectstaticadmin/css/base.csscopied from django.contrib.adminA break at any layer gives the same unstyled admindjango-deployment.com
ls the expected file under STATIC_ROOT to split collect problems from serve problems.
  • findstatic confirms Django can find the source file (requires django.contrib.staticfiles and django.contrib.admin in INSTALLED_APPS).
  • The ls confirms collectstatic copied it into STATIC_ROOT.

If the file is missing from STATIC_ROOT:

python manage.py collectstatic --noinput

Run it with the production settings module so STATIC_ROOT points at the production directory. A common mistake is running it with development settings, which collects into a different directory.

If the file exists but still returns 404, the web server mapping is wrong. For Nginx:

location /static/ {
    alias /srv/myapp/staticfiles/;
}

Both location and alias end with a slash, and alias points at STATIC_ROOT, not the app's source static/ folder. For WhiteNoise, confirm the middleware is present and placed directly after SecurityMiddleware:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...
]

WhiteNoise serves files from STATIC_ROOT as they existed when the process started, so restart the app after collectstatic.

3. Status 403: permissions

The web server user cannot read the file or traverse a parent directory. Check with:

namei -l /srv/myapp/staticfiles/admin/css/base.css
sudo -u www-data head -c 1 /srv/myapp/staticfiles/admin/css/base.css

See the dedicated Nginx 403 guide for fixes, including SELinux labels.

4. Status 200 but no styling: MIME type

Browsers refuse to apply a stylesheet served with the wrong Content-Type when X-Content-Type-Options: nosniff is set, which Django's SecurityMiddleware enables by default (SECURE_CONTENT_TYPE_NOSNIFF = True) and many Nginx configurations add too.

served, but refused by the browserHTTP/1.1200OKContent-Type:text/plainX-Content-Type-Options:nosniff1231The file exists and was served.2Should be text/css: mime.types not included.3So the browser refuses to apply it as a stylesheet.include mime.types; in the http block fixes itdjango-deployment.com
Custom Nginx configs copied without the http block often lose this line.
curl -sI https://www.example.com/static/admin/css/base.css | grep -i content-type

If it shows text/plain or application/octet-stream, Nginx is not loading its MIME type map. Make sure the http block includes it:

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    # ...
}

This line is present in distribution defaults but is sometimes lost in hand-written nginx.conf files or minimal container configurations. The browser console shows an error like "Refused to apply style ... because its MIME type ('text/plain') is not a supported stylesheet MIME type".

5. Status 200 but blocked: Content Security Policy

If you added a Content Security Policy, the browser console reports blocked resources, for example:

Refused to load the stylesheet 'https://cdn.example.net/static/admin/css/base.css'
because it violates the following Content Security Policy directive: "style-src 'self'".

Allow the origin that serves static files in style-src, script-src, img-src, and font-src. If static files come from a CDN, that CDN host must be listed.

6. Mixed content

On an HTTPS page, the browser blocks stylesheets requested over plain HTTP. This happens when STATIC_URL is an absolute http:// URL:

STATIC_URL = "http://cdn.example.com/static/"  # blocked on HTTPS pages

Use https:// or a relative path. The console shows a "Mixed Content" warning.

7. Deployments under a URL prefix

If the app is served under a subpath such as https://www.example.com/app/, a relative STATIC_URL = "static/" resolves under the script prefix in recent Django versions, but the web server must serve that path. Make both sides agree:

FORCE_SCRIPT_NAME = "/app"
STATIC_URL = "/app/static/"
when Django lives under /app/ValueFORCE_SCRIPT_NAME"/app" (or SCRIPT_NAME from the proxy)STATIC_URL✓"/app/static/"Nginx locationlocation /app/static/ { alias …; }All three must agree on the prefixdjango-deployment.com
Admin links break the same way when only one of these is set.
location /app/static/ {
    alias /srv/myapp/staticfiles/;
}

location /app/ {
    proxy_pass http://unix:/run/myapp/gunicorn.sock;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Also set SESSION_COOKIE_PATH and CSRF_COOKIE_PATH to the prefix if other apps share the domain.

8. Browser and CDN caching after the fix

After fixing a 404, browsers or a CDN may still hold the error or an old response. Test in a private window and with curl. With manifest storage, filenames change when content changes, so stale caching mostly affects unhashed paths.

9. Verify

curl -sI https://www.example.com/static/admin/css/base.css | grep -i -E "^HTTP|content-type"
curl -sI https://www.example.com/static/admin/js/core.js | grep -i -E "^HTTP|content-type"

Verification check:

  • both requests return 200
  • Content-Type is text/css and text/javascript (or application/javascript)
  • the admin login page is styled in a private browser window
  • the browser console shows no CSP or mixed content errors

Explanation

The admin's templates reference static files through {% static %}, which builds URLs from STATIC_URL and, with manifest storage, the hashed filenames in staticfiles.json. For those URLs to work, four things must line up: the files must be collected into STATIC_ROOT, something must serve STATIC_ROOT at STATIC_URL, the responses must carry correct content types, and the browser's security policies must allow them. An unstyled admin means one of those links is broken, and the network tab shows which one.

Edge cases / notes

Admin styled locally but not in Docker

The image may run collectstatic in a build stage but serve from a different container (for example Nginx) that does not share the collected files. Share a volume or copy the files into the Nginx image.

Only some admin pages unstyled

Third-party admin packages ship their own static files. If their app is missing from INSTALLED_APPS in production settings, their assets are not collected.

Admin JavaScript errors after an upgrade

After upgrading Django, stale cached copies of old admin JavaScript can break widgets. Manifest hashing avoids this; without it, purge caches for /static/admin/.

A deploy check for admin assets

Add an automated check at the end of each deploy so an unstyled admin is caught before anyone logs in:

#!/bin/bash
set -euo pipefail
BASE="${BASE:-https://www.example.com}"

css_path=$(curl -s "$BASE/admin/login/" | grep -o '/[^"]*admin/css/base[^"]*\.css' | head -1)
if [ -z "$css_path" ]; then
  echo "FAIL: no admin base.css reference found in login page"
  exit 1
fi

headers=$(curl -sI "$BASE$css_path")
echo "$headers" | head -1
echo "$headers" | grep -i "^content-type" || true

echo "$headers" | head -1 | grep -q " 200" || { echo "FAIL: $css_path not 200"; exit 1; }
echo "$headers" | grep -qi "^content-type: text/css" || { echo "FAIL: wrong content type"; exit 1; }
echo "admin CSS OK: $css_path"

The script reads the actual stylesheet URL from the rendered login page, so it works with manifest-hashed filenames and CDN URLs, then checks both the status and the Content-Type.

Where the admin's URLs come from

Understanding URL construction helps when paths look wrong:

  1. The admin template calls {% static "admin/css/base.css" %}.
  2. The staticfiles storage returns a name: plain (admin/css/base.css) or hashed (admin/css/base.1f3c9a2b7e4d.css) with manifest storage.
  3. That name is joined to STATIC_URL. A relative STATIC_URL such as static/ is prefixed with the script name (FORCE_SCRIPT_NAME or the WSGI SCRIPT_NAME), and an absolute URL such as https://cdn.example.com/static/ is used as is.

If the rendered URL is wrong, fix STATIC_URL or the storage configuration. If the URL is right but the response is wrong, fix whatever serves it.

Common mistakes

  • Pointing Nginx at the project's static/ source folder, which does not contain admin assets.
  • Running collectstatic on the build machine but deploying without the collected directory.
  • Forgetting to restart Gunicorn after collectstatic when using WhiteNoise or manifest storage.
  • Setting STATIC_URL without a leading or trailing slash in older configurations, producing URLs relative to the current page.

Next steps

FAQ

Why is the Django admin unstyled in production but fine locally?

Locally, runserver serves static files automatically in debug mode. In production, you must run collectstatic and serve STATIC_ROOT with WhiteNoise or a web server.

Where are Django admin CSS files located?

They ship inside Django at django/contrib/admin/static/admin/. collectstatic copies them into STATIC_ROOT/admin/, which is where production servers should serve them from.

Why does the admin CSS return 200 but the page is still unstyled?

The file may be served with the wrong Content-Type, blocked by a Content Security Policy, or loaded over HTTP on an HTTPS page. Check the browser console for the specific error.

Do I need to run collectstatic after upgrading Django?

Yes. Admin static files can change between Django versions, so run collectstatic on every deploy that changes dependencies.

Keep going