Django Intermediate #6: Static/Media Operations and Storage Backends
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 #
| Static | Media | |
|---|---|---|
| Origin | Developer (committed to the repo) | User upload |
| Change frequency | Per deploy | Real-time |
| Backup need | Low (lives in the repo) | High (loss is final) |
| CDN | Almost always | Sometimes |
| Caching | Strong 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.
STATIC_URL = "/static/"
STATICFILES_DIRS = [
BASE_DIR / "static", # project-wide static files
]
STATIC_ROOT = BASE_DIR / "staticfiles" # output of collectstatic| Setting | Meaning | Use |
|---|---|---|
STATIC_URL | URL prefix the browser sees | /static/css/style.css |
STATICFILES_DIRS | Extra search directories at dev time | Project-wide static files |
STATIC_ROOT | Where collectstatic collects to | What nginx serves in production |
In addition, per-app static/ directories are auto-discovered by AppDirectoriesFinder. Form: blog/static/blog/style.css.
In templates #
{% 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).
python manage.py collectstatic --noinput--noinput automatically answers “yes” to any “Overwrite existing files?” prompts. Required in CI.
Deploy flow:
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.
staticfiles/
media/Media — user-uploaded files #
Models — FileField, ImageField
#
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 underMEDIA_ROOT/avatars/"covers/%Y/%m/"— auto year/month folders (covers/2026/05/)- callable is also possible — function for a dynamic path
ImageField requires Pillow.
pip install PillowSettings — MEDIA_URL, MEDIA_ROOT
#
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.
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 #
{% 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.
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
#
pip install django-storages[s3] boto3INSTALLED_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 = {
"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 #
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.
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.
pip install whitenoiseMIDDLEWARE = [
"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
python manage.py collectstatic --noinputWhiteNoise 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.
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.
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:
| Library | Features |
|---|---|
| django-imagekit | Define specs on model fields, generate on request or in advance |
| sorl-thumbnail | Generate immediately via template tag, with caching |
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_HOSTSset -
STATIC_ROOTconfigured + added to.gitignore -
python manage.py collectstatic --noinputincluded 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 (#5 —
SECURE_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 timeMEDIA_URL,MEDIA_ROOT, the model’sFileField/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 Storageabstraction 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.