Django static files 404 in production
THE SHORT ANSWER
Django only serves static files itself while DEBUG is True. Its docs call that method grossly inefficient and probably insecure, so with DEBUG = False in production every /static/ URL returns 404 and the admin loses its styling. The fix has three parts: set STATIC_ROOT, run python manage.py collectstatic during the build, and serve the collected folder, usually with WhiteNoise added to MIDDLEWARE right after SecurityMiddleware.
Why do static files work locally but 404 in production?
In development, runserver serves files from each app's static/ folder for you, but only while DEBUG = True. Django's docs say this method “is grossly inefficient and probably insecure, so it is unsuitable for production.” Set DEBUG = False, as you should in production, and Django stops answering /static/ URLs. Your pages still render, but the CSS and JavaScript they link to come back 404. The admin is the first place people notice it: it loads as plain, unstyled HTML.
Production needs two things Django doesn't do by default: copy every static file into one folder, and have something serve that folder.
Which settings do I need?
STATIC_URLis the URL prefix, such as"static/". It must end in a slash.STATIC_ROOTis the foldercollectstaticcopies everything into. It defaults toNone, andcollectstaticrefuses to run until you set it.STATICFILES_DIRSlists extra folders to collect from, such as a project-levelstatic/folder that isn't inside an app. Don't putSTATIC_ROOTin this list.
# settings.py from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / "static"] # only if you have this folder
Then run python manage.py collectstatic --noinput. It copies your apps' files, the admin's files and anything in STATICFILES_DIRS into staticfiles/. Run it during the build, not by hand, so every deploy ships the files that match its code. Add staticfiles/ to .gitignore.
How do I serve the files with WhiteNoise?
On a platform that runs your app in a container, there is no separate Nginx to point at STATIC_ROOT. WhiteNoise lets the Django process serve the collected files itself, with caching headers and compression. Install it (pip install whitenoise, version 6.12 at the time of writing) and add its middleware directly after SecurityMiddleware:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# ... the rest
]
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}The storage backend compresses each file and adds a hash of its contents to the name, such as app.3f2a9c1e.css, so browsers can cache it for a long time and still get the new file after a deploy. Use {% static 'app.css' %} in templates, never a hardcoded /static/app.css, or the hashed name is never used.
To use WhiteNoise in development as well, add "whitenoise.runserver_nostatic" to the top of INSTALLED_APPS.
STATICFILES_STORAGE stopped working?
Django 5.1 removed the STATICFILES_STORAGE setting, after deprecating it in 4.2. Older tutorials still show STATICFILES_STORAGE = "whitenoise.storage..."; on 5.1 and later that line is ignored and you get Django's default storage instead. Move it into STORAGES under the "staticfiles" key, as above. When you define STORAGES, include the "default" entry too, since it replaces the whole default dictionary.
ValueError: Missing staticfiles manifest entry
Once manifest storage is on, a page can fail with a 500 and this in the logs:
ValueError: Missing staticfiles manifest entry for 'css/app.css'
Manifest storage looks up every {% static %} path in staticfiles.json, which collectstatic writes. Django's docs: “If a file isn't found in the staticfiles.json manifest at runtime, a ValueError is raised.” The usual causes:
collectstaticdidn't run in this deploy, so there is no manifest at all.- A typo in the path, or a file that only exists on your machine and was never committed.
- A file in a folder Django doesn't search, such as a project-level
static/that is missing fromSTATICFILES_DIRS.
Run python manage.py collectstatic --noinput locally and read its output, or python manage.py findstatic css/app.css to see where Django looks. Setting manifest_strict = False on a storage subclass hides the error, but the file still 404s.
What about uploaded files?
Files users upload go to MEDIA_ROOT, which is not static. WhiteNoise's docs say it “is not suitable for serving user-uploaded media files.” Django's settings docs also require MEDIA_ROOT and STATIC_ROOT to be different folders.
On a container platform, a container's disk is replaced on every deploy, so uploads saved there disappear. Store them in object storage instead (S3 or anything S3-compatible, through django-storages), set as the "default" backend in STORAGES.
Can I just use runserver --insecure?
runserver --insecure forces Django to serve static files with DEBUG = False. It is meant for checking a production-like setup on your machine, it doesn't work with manifest storage, and runserver itself is a development server. In production, run Gunicorn or another WSGI server and serve static files with WhiteNoise or a web server in front.
What happens on EthioDeploy?
EthioDeploy detects Django from manage.py and starts it with migrate and Gunicorn, but it doesn't run collectstatic for you. Set the Build command in your project settings to python manage.py collectstatic --noinput, add WhiteNoise as above, and redeploy.
The rest of the production settings, including ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS and Postgres, are in the Django deploy guide.
Questions
Django's built-in static file serving only runs while DEBUG is True, because its docs call it grossly inefficient and probably insecure. In production you run collectstatic and serve STATIC_ROOT with WhiteNoise or a web server.
The admin's CSS is a static file like any other. With DEBUG = False nothing serves it until you set STATIC_ROOT, run collectstatic and add WhiteNoise or another static file server.
Directly after django.middleware.security.SecurityMiddleware, above every other middleware.
The STORAGES setting. Django 5.1 removed STATICFILES_STORAGE; put the backend under STORAGES['staticfiles']['BACKEND'], and keep a 'default' entry for media files.
Make sure collectstatic runs in every deploy, then check that the file exists, is committed and sits in an app's static folder or a STATICFILES_DIRS folder. findstatic shows where Django looks for it.
Sources
Checked on November 7, 2026:
Join the EthioDeploy Telegram
Release notes as they ship, plus deploy help from the team and other developers.