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
runserverserves the finders' output directly, so edits appear after a refresh. In production nothing serves them untilcollectstatichas copied everything intoSTATIC_ROOT. ManifestStaticFilesStoragewritesstaticfiles.jsonmapping each file to a content hash. The hash in the filename is what lets you set a one-year cache header safely.- Set
STATIC_ROOTto a path that is notSTATICFILES_DIRSand is not inside your source tree —collectstaticclears 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| Setting | Purpose | Development | Production |
|---|---|---|---|
STATIC_URL | Public prefix for CSS/JS | /static/ | CDN or nginx path |
STATIC_ROOT | collectstatic target | Unused by runserver | Served by nginx or WhiteNoise |
STATICFILES_DIRS | Source folders you edit | Project static/ | Same, plus app folders |
MEDIA_URL | Public prefix for uploads | /media/ | Object storage or nginx |
MEDIA_ROOT | Where files are written | Local folder | Volume 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 withDEBUG=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.Related
Templates and the Django template language Deployment: gunicorn, static files and the security checklist
Last refreshed 2026-09-18.