Django Intermediate #6: Static/Media Operations and Storage Backends

8 min read

In Basics #5, static files were introduced for the first time. That worked because the dev server (python manage.py runserver) serves them automatically. Once you cross into production, that disappears — by convention, Django does not serve static files in production.

This post fills that gap, covering the two kinds of files and the patterns for handling them in production.

  • Static — files made by the developer (CSS/JS/images/fonts)
  • Media — files uploaded by users (profile photos, attachments, etc.)

Static vs Media — why split them #

StaticMedia
OriginDeveloper (committed to the repo)User upload
Change frequencyPer deployReal-time
Backup needLow (lives in the repo)High (loss is final)
CDNAlmost alwaysSometimes
CachingStrong caching (hashed filenames)Moderate/short caching

Because of this difference, Django keeps the two systems separated. Settings are separate, handling is separate.

Static — files made by the developer #

Three settings — STATIC_URL, STATICFILES_DIRS, STATIC_ROOT #

The three names look similar and are a common source of confusion.

settings.py
STATIC_URL = "/static/"

STATICFILES_DIRS = [
    BASE_DIR / "static",            # project-wide static files
]

STATIC_ROOT = BASE_DIR / "staticfiles"   # output of collectstatic
SettingMeaningUse
STATIC_URLURL prefix the browser sees/static/css/style.css
STATICFILES_DIRSExtra search directories at dev timeProject-wide static files
STATIC_ROOTWhere collectstatic collects toWhat nginx serves in production

In addition, per-app static/ directories are auto-discovered by AppDirectoriesFinder. Form: blog/static/blog/style.css.

In templates #

template
{% load static %}

<link rel="stylesheet" href="{% static 'css/style.css' %}">
<img src="{% static 'images/logo.png' %}" alt="logo">
<script src="{% static 'js/app.js' %}"></script>

The {% static %} tag combines STATIC_URL + the file path.

collectstatic — the essence of production deployment #

At dev time, static files are scattered across many places:

  • Per-app static/ directories
  • Directories in STATICFILES_DIRS
  • Static files of external libraries (django.contrib.admin, etc.)

collectstatic gathers them into one place (STATIC_ROOT).

run once at deploy
python manage.py collectstatic --noinput

--noinput automatically answers “yes” to any “Overwrite existing files?” prompts. Required in CI.

Deploy flow:

deploy steps
1. pull code / build image
2. python manage.py migrate           ← DB schema
3. python manage.py collectstatic --noinput  ← gather static files
4. start new process (gunicorn restart, etc.)

STATIC_ROOT is gitignore #

STATIC_ROOT is a directory that collectstatic rebuilds on every deploy. Don’t commit it to git.

.gitignore
staticfiles/
media/

Media — user-uploaded files #

Models — FileField, ImageField #

blog/models.py
from django.db import models

class Profile(models.Model):
    user = models.OneToOneField("auth.User", on_delete=models.CASCADE)
    avatar = models.ImageField(upload_to="avatars/%Y/%m/", blank=True)

class Post(models.Model):
    title = models.CharField(max_length=200)
    cover = models.ImageField(upload_to="covers/", blank=True)
    attachment = models.FileField(upload_to="attachments/%Y/%m/", blank=True)

Behavior of upload_to:

  • "avatars/" — saved under MEDIA_ROOT/avatars/
  • "covers/%Y/%m/" — auto year/month folders (covers/2026/05/)
  • callable is also possible — function for a dynamic path

ImageField requires Pillow.

install
pip install Pillow

Settings — MEDIA_URL, MEDIA_ROOT #

settings.py
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

Serving in dev — one line #

Django doesn’t serve media in production, but for local development convenience you can enable it with a one-line addition.

config/urls.py — for dev
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    ...
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

The if settings.DEBUG guard ensures it doesn’t run in production.

In templates #

template
{% if user.profile.avatar %}
  <img src="{{ user.profile.avatar.url }}" alt="avatar">
{% endif %}

avatar.url automatically combines MEDIA_URL + the storage path.

Production — Django doesn’t serve static files #

The three standard patterns for static/media serving in production:

Pattern 1 — nginx directly #

The most traditional and fastest method.

part of nginx.conf
server {
    listen 80;
    server_name myblog.com;

    location /static/ {
        alias /srv/myblog/staticfiles/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    location /media/ {
        alias /srv/myblog/media/;
        expires 30d;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;   # gunicorn
    }
}

/static/ and /media/ are served by nginx directly from disk. Other requests go to gunicorn (Django).

Pattern 2 — cloud storage (S3, etc.) #

When scale grows or you run multiple servers, sharing a disk becomes a problem. Move to object storage like S3, GCS, or Azure Blob.

Pattern 3 — WhiteNoise (for small apps) #

For simple apps, you can serve static files in front of gunicorn without nginx.

django-storages + S3 — the full pattern #

install
pip install django-storages[s3] boto3
settings.py — media to S3
INSTALLED_APPS = [
    ...
    "storages",
]

# AWS credentials (recommend reading from env vars)
AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
AWS_STORAGE_BUCKET_NAME = "my-blog-media"
AWS_S3_REGION_NAME = "ap-northeast-2"
AWS_S3_CUSTOM_DOMAIN = f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
AWS_DEFAULT_ACL = None
AWS_QUERYSTRING_AUTH = False    # if the bucket is public

# Django 4.2+ STORAGES setting
STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3.S3Storage",
    },
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

With this in place, calls like Post.cover.save(...) automatically upload to S3. Not a single line of model code changes. cover.url also automatically returns the S3 URL.

Django 4.2+ STORAGES setting #

Since Django 4.2, DEFAULT_FILE_STORAGE / STATICFILES_STORAGE are deprecated. Use the unified STORAGES dict.

STORAGES — static local, media to S3
STORAGES = {
    "default": {                                   # media (FileField/ImageField on models)
        "BACKEND": "storages.backends.s3.S3Storage",
    },
    "staticfiles": {                                # static files
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

Static to S3 too — CDN effect #

static also S3 + CloudFront
STORAGES = {
    "default": {
        "BACKEND": "storages.backends.s3.S3Storage",
        "OPTIONS": {"location": "media"},
    },
    "staticfiles": {
        "BACKEND": "storages.backends.s3.S3Storage",
        "OPTIONS": {"location": "static"},
    },
}

collectstatic automatically uploads to S3. Putting a CDN like CloudFront in front gives you globally fast static serving.

One line on security — credentials #

Never put AWS keys in code or the repo. Use environment variables, a secret manager, or an IAM Role — anything that keeps them out of the codebase.

✅ from env vars
AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]

On EC2/ECS/EKS, attaching an IAM Role and avoiding key management in code altogether is the safest option.

WhiteNoise — the answer for small apps #

For small apps where a separate nginx setup or S3 is overkill, WhiteNoise is the answer. It serves static files directly in front of gunicorn.

install
pip install whitenoise
settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",   # immediately after SecurityMiddleware
    ...
]

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

What CompressedManifestStaticFilesStorage is:

  • manifest — file content hash included in filename (style.abc123.css). New URL on change → automatic cache invalidation
  • compressed — pre-builds gzip / brotli compressed versions
at deploy
python manage.py collectstatic --noinput

WhiteNoise serves directly from STATIC_ROOT. It’s the go-to solution when deploying to PaaS platforms like Heroku, Railway, or Fly without a separate nginx layer.

Static only, not Media #

WhiteNoise does not handle user uploads (media). Media files still need external storage like S3. For apps without user uploads, WhiteNoise alone is sufficient.

Storage backend abstraction #

Django abstracts all file storage behind the Storage class. With FileField’s storage= argument, you can use different storage backends per model.

different storage per model
from storages.backends.s3 import S3Storage

private_storage = S3Storage(bucket_name="my-private-bucket", default_acl="private")

class Document(models.Model):
    file = models.FileField(upload_to="docs/", storage=private_storage)

Private documents in a separate private bucket, public images in the default bucket — these kinds of separations are straightforward.

Pre-signed URL — sharing private files #

Even if an S3 bucket is private, you can issue a time-limited URL for temporary access.

signed URL
url = private_storage.url(document.file.name)
# https://...amazonaws.com/...?X-Amz-Algorithm=...&Expires=...

Settings like AWS_QUERYSTRING_AUTH = True and AWS_QUERYSTRING_EXPIRE = 3600 (1 hour) control expiry.

Image processing — one-line note #

If you need to thumbnail or resize uploaded images, two common libraries are:

LibraryFeatures
django-imagekitDefine specs on model fields, generate on request or in advance
sorl-thumbnailGenerate immediately via template tag, with caching
imagekit example
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFill

class Profile(models.Model):
    avatar = models.ImageField(upload_to="avatars/")
    avatar_thumb = ImageSpecField(
        source="avatar",
        processors=[ResizeToFill(150, 150)],
        format="JPEG",
        options={"quality": 85},
    )

Declare per-size variants on the model and the library generates them automatically. If image processing is expensive, offloading it to a background job (DRF #4 Celery) is a common pattern.

Real-world checklist — before production deploy #

  • DEBUG = False
  • ALLOWED_HOSTS set
  • STATIC_ROOT configured + added to .gitignore
  • python manage.py collectstatic --noinput included in deploy script
  • Decide between nginx / WhiteNoise / S3 for static serving
  • Media file backup policy (versioning / lifecycle if S3)
  • AWS credentials via env vars / IAM Role
  • Security headers (#5SECURE_SSL_REDIRECT, HSTS, etc.)

Summary #

What we covered in this post:

  • Static vs Media — two kinds, differing in developer/user origin, change frequency, backup need
  • STATIC_URL (browser prefix), STATICFILES_DIRS (dev search paths), STATIC_ROOT (collectstatic output)
  • collectstatic — gathers scattered static files in one place at deploy time
  • MEDIA_URL, MEDIA_ROOT, the model’s FileField / ImageField, upload_to
  • Serve via static(settings.MEDIA_URL, ...) only in dev
  • In production Django doesn’t serve static files — nginx / S3 / WhiteNoise
  • django-storages[s3] + STORAGES (4.2+) for S3 transition
  • WhiteNoise — answer for small apps, one middleware line + CompressedManifestStaticFilesStorage
  • Storage abstraction for per-model different storage
  • Pre-signed URL for time-limited sharing of private files
  • Image processing: django-imagekit, sorl-thumbnail

In the next post (#7 Testing), the last of intermediate — testing. django.test.TestCase, fixtures, factory_boy, pytest-django — all in one place.

X