Static and Media Files in Production

Introduction

Chapter Templates and Static Files covered {% static %} in development. Production adds user uploads (media), collectstatic to gather assets for deployment, and Nginx (or WhiteNoise) to serve files efficiently. This chapter configures STATIC_* and MEDIA_*, handles file uploads safely, and wires a minimal Nginx config.

Prerequisites

Static vs Media

TypeSourceServed in prod by
StaticYour CSS/JS/images in repocollectstatic → Nginx or WhiteNoise
MediaUser uploads at runtimeNginx alias to MEDIA_ROOT

Never commit MEDIA_ROOT user files to Git—add to .gitignore.

Development Settings

config/settings/base.py:

python
STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR / "static",
]
STATIC_ROOT = BASE_DIR / "staticfiles"
 
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"

Layout:

text
myproject/
├── static/
│   └── css/site.css
├── media/              # created at runtime
└── blog/static/blog/   # app-level static (optional)

blog/static/blog/css/style.css is found automatically via django.contrib.staticfiles.

Serve Media in Development Only

config/urls.py:

python
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
 
urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("blog.urls")),
]
 
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Warning

Never Use Django to Serve Media in Production

static() helper is for local dev. Production must serve MEDIA_ROOT via Nginx or object storage (S3)—Django workers are not a CDN.

File Upload Model and Form

blog/models.py:

python
def upload_to_post_image(instance, filename):
    return f"posts/{instance.pk or 'new'}/{filename}"
 
 
class Post(models.Model):
    ...
    cover = models.ImageField(upload_to=upload_to_post_image, blank=True)

Install Pillow:

bash
pip install Pillow

blog/forms.py:

python
class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ["title", "slug", "body", "cover"]

Template:

html
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Save</button>
</form>

View must not parse files manually—request.FILES is bound into ModelForm(request.POST, request.FILES).

Validate uploads in production:

  • Max size in settings or form clean_cover
  • Allow only image MIME types
  • Scan with antivirus in high-security environments

collectstatic

Before deploy, gather all static files into STATIC_ROOT:

bash
python manage.py collectstatic --noinput

Output lands in staticfiles/—deploy that directory to the server or bake into Docker image.

ManifestStaticFilesStorage (optional) hashes filenames for cache busting:

python
STORAGES = {
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
    },
}

Django 4.2+ uses STORAGES dict; older projects used STATICFILES_STORAGE.

WhiteNoise (Small Projects)

Serve static from the same Gunicorn process without Nginx for static only:

bash
pip install whitenoise

MIDDLEWARE — insert after SecurityMiddleware:

python
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    ...
]

settings/production.py:

python
STORAGES = {
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

Media uploads still need Nginx or S3—WhiteNoise is for static only.

Nginx: Static + Media + Gunicorn

Minimal server block (adapt paths):

Run collectstatic on the server after each deploy. Ensure Nginx user can read staticfiles/ and write is not needed for static.

Full reverse-proxy patterns — Nginx reverse proxy and Django deployment.

Object Storage (Awareness)

At scale, store media on S3 with django-storages—Django saves the URL; files never touch local disk. Same pattern for CDN-backed static.

Post-Chapter Checklist

  • STATIC_URL, STATIC_ROOT, MEDIA_URL, MEDIA_ROOT defined
  • Dev urls.py serves media only when DEBUG
  • Upload form uses enctype="multipart/form-data"
  • You ran collectstatic and know where files land

FAQ

Static file 404 in production?

Forgot collectstatic, wrong alias path, or permissions.

Duplicate static in app and project?

staticfiles finders merge STATICFILES_DIRS and app static/ folders.

Serve user avatars?

ImageField on User profile model + Nginx /media/ alias.

DEBUG=True static works, prod broken?

Dev uses runserver static finder; prod needs collectstatic + Nginx/WhiteNoise.

Compare Flask?

Flask send_from_directory for uploads in dev; prod same Nginx pattern — Flask file uploads.