Static and media files

Serve CSS, JavaScript and images correctly in development and production, and accept user uploads without turning your server into a file host.

Static files

# config/settings.py
STATIC_URL = "static/"                       # URL prefix, not a directory
STATICFILES_DIRS = [BASE_DIR / "static"]     # extra source folders you edit
STATIC_ROOT = BASE_DIR / "staticfiles"       # collectstatic output, never edited

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
}
{% load static %}
<link rel="stylesheet" href="{% static 'css/app.css' %}">
<img src="{% static 'img/logo.svg' %}" alt="Logo" width="120" height="32">
{# with ManifestStaticFilesStorage this becomes /static/css/app.9a1c2f3d.css #}
  • In development runserver serves the finders' output directly, so edits appear after a refresh. In production nothing serves them until collectstatic has copied everything into STATIC_ROOT.
  • ManifestStaticFilesStorage writes staticfiles.json mapping each file to a content hash. The hash in the filename is what lets you set a one-year cache header safely.
  • Set STATIC_ROOT to a path that is not STATICFILES_DIRS and is not inside your source tree — collectstatic clears untracked files there on every run.

Media and user uploads

# config/settings.py
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"          # in production: a mounted volume or object storage

# blog/models.py
import uuid
from pathlib import Path
from django.db import models

def upload_to(instance, filename):
    ext = Path(filename).suffix.lower()
    return f"avatars/{instance.pk or 'new'}/{uuid.uuid4().hex}{ext}"

class Profile(models.Model):
    user = models.OneToOneField("accounts.User", on_delete=models.CASCADE)
    avatar = models.ImageField(upload_to=upload_to, blank=True)
    resume = models.FileField(upload_to="resumes/%Y/%m/", blank=True)
# blog/forms.py
from django import forms
from .models import Profile

class ProfileForm(forms.ModelForm):
    class Meta:
        model = Profile
        fields = ["avatar"]

    def clean_avatar(self):
        f = self.cleaned_data["avatar"]
        if f and f.size > 2 * 1024 * 1024:
            raise forms.ValidationError("Images must be under 2 MB.")
        if f and not f.name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
            raise forms.ValidationError("PNG, JPEG or WebP only.")
        return f
SettingPurposeDevelopmentProduction
STATIC_URLPublic prefix for CSS/JS/static/CDN or nginx path
STATIC_ROOTcollectstatic targetUnused by runserverServed by nginx or WhiteNoise
STATICFILES_DIRSSource folders you editProject static/Same, plus app folders
MEDIA_URLPublic prefix for uploads/media/Object storage or nginx
MEDIA_ROOTWhere files are writtenLocal folderVolume or bucket

Serving files in each environment

# config/urls.py — development only
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("blog.urls")),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
location /static/ {
    alias /srv/app/staticfiles/;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

location /media/ {
    alias /srv/app/media/;
    expires 30d;
    add_header X-Content-Type-Options nosniff;
}
  • Django's static() helper refuses to work with DEBUG=False — it is a development convenience, not a web server.
  • In production a reverse proxy serves both prefixes; Django never handles a byte of them. That keeps Python workers free for application requests.
  • Validation happens on upload, but sanitising an image (strip metadata, re-encode, cap dimensions) needs Pillow or an image service. An uploaded SVG or HTML file served from your origin is an XSS vector.
⚠️
Never trust an uploaded filename or MIME type. A path like ../../etc/cron.d/x escapes the upload directory, a .php or .html file in a served directory becomes executable content in the visitor's browser, and a content_type header is just a string the client chose. Generate your own name, keep an allowlist of extensions, cap the size, and store uploads where the web server cannot execute them.

FAQ

Why is my CSS 404 in production but fine locally?
You skipped collectstatic, or set STATIC_ROOT somewhere your web server does not look. Run python manage.py collectstatic --noinput as a release step and confirm the proxy's alias points at that exact directory.
Should uploads live in MEDIA_ROOT forever?
For a single server, yes, with a backup. As soon as you run more than one instance, a local directory stops working because each worker sees a different disk; move to object storage (S3-compatible) via django-storages and set STORAGES accordingly.

Templates and the Django template language Deployment: gunicorn, static files and the security checklist

Last refreshed 2026-09-18.