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.
Quick answer
- Open the browser developer tools on an admin page and check the Network tab for
admin/css/base.css: note the URL, status code, andContent-Type. - 404: run
collectstatic, checkSTATIC_ROOT, and make sure the web server or WhiteNoise serves that directory atSTATIC_URL. - 403: fix filesystem permissions on the static directory for the web server user.
- 200 but styles not applied: check
Content-Type(must betext/css), Content Security Policy errors in the console, and mixed content warnings. - Wrong URL (for example missing a prefix): set
STATIC_URLto the public path, including any subpath the app is mounted under. - 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"'
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
findstaticconfirms Django can find the source file (requiresdjango.contrib.staticfilesanddjango.contrib.admininINSTALLED_APPS).- The
lsconfirmscollectstaticcopied it intoSTATIC_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.
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/"
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-Typeistext/cssandtext/javascript(orapplication/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:
- The admin template calls
{% static "admin/css/base.css" %}. - The staticfiles storage returns a name: plain (
admin/css/base.css) or hashed (admin/css/base.1f3c9a2b7e4d.css) with manifest storage. - That name is joined to
STATIC_URL. A relativeSTATIC_URLsuch asstatic/is prefixed with the script name (FORCE_SCRIPT_NAMEor the WSGISCRIPT_NAME), and an absolute URL such ashttps://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
collectstaticon the build machine but deploying without the collected directory. - Forgetting to restart Gunicorn after
collectstaticwhen using WhiteNoise or manifest storage. - Setting
STATIC_URLwithout a leading or trailing slash in older configurations, producing URLs relative to the current page.
Next steps
- Work through all static scenarios in Django Static Files Not Loading in Production.
- For permission errors, see Fix Nginx 403 Forbidden on Django Static Files.
- Serve files from the app process with How to Serve Django Static Files with WhiteNoise.
- Lock the admin down with the Django Admin Security Checklist.
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.