Django serves static files only in development (DEBUG=True). In production, the admin looks unstyled, and CSS returns 404, until you configure static files properly. You also need a plan for media (user uploads). Here’s how both work, with Nginx and with WhiteNoise.
Full deployment: deploy Django with Gunicorn and Nginx.
Static vs media
| Type | What | Settings |
|---|---|---|
| Static files | Your CSS, JS, images, and the admin’s assets (part of the code) | STATIC_URL, STATIC_ROOT, STATICFILES_DIRS |
| Media files | Files uploaded by users at runtime | MEDIA_URL, MEDIA_ROOT |
1. Settings
# settings.py
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles" # collectstatic writes here
STATICFILES_DIRS = [BASE_DIR / "static"] # your project's own static sources
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
2. Collect static files on deploy
python manage.py collectstatic --noinput
This copies every app’s static files (including the Django admin) into STATIC_ROOT. Run it on every deploy.
3a. Serve with Nginx (recommended on a VPS)
server {
server_name example.com;
location /static/ {
alias /opt/app/staticfiles/;
expires 30d;
add_header Cache-Control "public";
access_log off;
}
location /media/ {
alias /opt/app/media/;
expires 7d;
}
location / {
proxy_pass http://127.0.0.1:8000; # gunicorn
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Note the trailing slashes on location /static/ and alias …/. A mismatch is the most common cause of 404s. Nginx must be able to read the files (directories need x permission for the Nginx user along the whole path). See Linux file permissions explained and Nginx static file caching.
3b. Or serve static files with WhiteNoise
WhiteNoise lets Gunicorn serve static files efficiently, with compression and caching headers. It’s handy in Docker, or on PaaS platforms:
pip install whitenoise
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# …
]
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
WhiteNoise doesn’t serve media files. Use Nginx or object storage for uploads.
Cache busting with manifest storage
ManifestStaticFilesStorage (or WhiteNoise’s compressed variant) renames files with a content hash (app.4f2a1c.css), so you can cache them for a year, and browsers still get new versions after deploys:
location /static/ {
alias /opt/app/staticfiles/;
expires 1y;
add_header Cache-Control "public, immutable";
}
Media files: security and scaling
- Never serve user uploads from a path that can execute code, and validate file types and sizes. Set
client_max_body_sizein Nginx for upload limits. See Nginx 413 errors. - Private files (invoices, documents) shouldn’t sit in a public
/media/location. Serve them through a Django view that checks permissions, then hands off to Nginx withX-Accel-Redirect. - Multiple servers, or containers: store media in S3-compatible object storage with
django-storages, so every instance sees the same files. See MinIO. - Back up
MEDIA_ROOT. Static files can be regenerated, but uploads can’t. See backup strategy.
Troubleshooting
| Symptom | Cause |
|---|---|
| Admin has no styling | collectstatic not run, or Nginx alias path wrong |
404 on /static/… |
Trailing slash mismatch; wrong STATIC_ROOT |
| 403 on static files | Nginx can’t traverse directories (permissions) |
| Old CSS after deploy | No manifest storage, and long cache headers |
| Uploads 404 | MEDIA_ROOT not served; wrong alias |
Quick reference
| Task | Setting / command |
|---|---|
| Static destination | STATIC_ROOT |
| Gather files | python manage.py collectstatic --noinput |
| Serve (VPS) | Nginx location /static/ { alias …/; } |
| Serve (app) | WhiteNoise middleware |
| Cache busting | Manifest static storage |
| Uploads at scale | django-storages + S3/MinIO |
Related: Django production settings and Celery workers.