Static & Media Files on S3

Reviewed & published by Brayan K

In production your Django app server should run code, not stream files. Cloud object storage like Amazon S3 — wired up with django-storages and boto3 — gives you durable, scalable storage for both your collected static assets and your users' uploaded media.

Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll configure the modern STORAGES setting with S3Boto3Storage backends, supply credentials safely from the environment, push files up with collectstatic, choose between public and signed URLs, and put a CDN in front for fast global delivery.

What You'll Learn in This Lesson

1 Why Object Storage Instead of the App Server

When DEBUG = False, Django stops serving static files, and serving user uploads through gunicorn ties up workers and doesn't survive a redeploy on ephemeral disks. Amazon S3 is durable, effectively unlimited object storage that any number of servers can read from — perfect for both your collected static assets and your users' media.

The two pieces you need are django-storages (which provides storage backends) and boto3 (the AWS SDK it uses to talk to S3).

# Install the storage backend and the AWS SDK
pip install django-storages boto3

# Add the app to INSTALLED_APPS in settings.py
INSTALLED_APPS = [
    # ...
    "storages",
]
# An app server has a fixed number of workers. If they spend time
# streaming file bytes, they can't handle real requests. Object
# storage offloads that work. Let's model the difference.

workers = 4
request_seconds = 0.05        # time to render a normal page
file_stream_seconds = 2.0     # time to stream a big media file

# If every worker streams a file, throughput collapses
streaming_capacity = workers / file_stream_seconds
serving_capacity = workers / request_seconds

print("Requests/sec if workers stream files:", round(streaming_capacity, 1))
print("Requests/sec if S3 serves the files: ", round(serving_capacity, 1))
print("Speedup from offloading to S3:        ",
      round(serving_capacity / streaming_capacity, 1), "x")

# ✅ Expected output:
# Requests/sec if workers stream files: 2.0
# Requests/sec if S3 serves the files:  80.0
# Speedup from offloading to S3:         40.0 x

2 Configuring the STORAGES Setting

Django 4.2+ centralizes storage configuration in a single STORAGES dict. The default entry handles media (user uploads) and the staticfiles entry handles your collected static assets. Each points at S3Boto3Storage, and the bucket, region, and credentials come from environment variables — never hard-coded.

# settings.py
import os

AWS_STORAGE_BUCKET_NAME = os.environ["AWS_STORAGE_BUCKET_NAME"]
AWS_S3_REGION_NAME = os.environ.get("AWS_S3_REGION_NAME", "us-east-1")

# Credentials: read from the environment, or use an IAM role on AWS.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")

STORAGES = {
    # Media (user uploads) -> S3
    "default": {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        "OPTIONS": {"location": "media"},
    },
    # Collected static files -> S3
    "staticfiles": {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        "OPTIONS": {"location": "static"},
    },
}

# Public URLs for the browser to load assets from
STATIC_URL = f"https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/static/"
MEDIA_URL = f"https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/media/"
# The STORAGES setting is just a dict: a "default" backend for media
# and a "staticfiles" backend for static. We'll build it the way
# settings.py does and read the bucket from the environment.

import os

# Pretend the host set this; never hard-code it in code.
env = {"AWS_STORAGE_BUCKET_NAME": "my-app-assets"}
bucket = env["AWS_STORAGE_BUCKET_NAME"]

S3 = "storages.backends.s3boto3.S3Boto3Storage"
STORAGES = {
    "default":     {"BACKEND": S3, "OPTIONS": {"location": "media"}},
    "staticfiles": {"BACKEND": S3, "OPTIONS": {"location": "static"}},
}

for name, cfg in STORAGES.items():
    loc = cfg["OPTIONS"]["location"]
    print(f"{name:11} -> s3://{bucket}/{loc}/  via {cfg['BACKEND'].split('.')[-1]}")

# ✅ Expected output:
# default     -> s3://my-app-assets/media/  via S3Boto3Storage
# staticfiles -> s3://my-app-assets/static/  via S3Boto3Storage

3 collectstatic, Public vs Signed URLs, and a CDN

With the staticfiles backend pointed at S3, collectstatic uploads every static file straight into the bucket. For media, public assets can be world-readable, while private files are served with time-limited signed URLs. In front of the bucket you usually place a CDN like CloudFront for fast, cheap delivery.

# Upload all collected static files into the S3 bucket
python manage.py collectstatic --noinput
# 142 static files copied to 's3://my-app-assets/static/'.
# settings.py — public static, private media with signed URLs
STORAGES = {
    "default": {                       # media: private + signed URLs
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        "OPTIONS": {
            "location": "media",
            "default_acl": "private",
            "querystring_auth": True,   # generate expiring signed URLs
            "querystring_expire": 3600, # links valid for 1 hour
        },
    },
    "staticfiles": {                   # static: public, cached by the CDN
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        "OPTIONS": {"location": "static", "querystring_auth": False},
    },
}

# Serve everything through a CloudFront CDN on your own domain
AWS_S3_CUSTOM_DOMAIN = "cdn.example.com"
STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/static/"
MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/media/"

Because the backend speaks the S3 API, the very same setup works with S3-compatible stores like MinIO, Cloudflare R2, and DigitalOcean Spaces — you just point AWS_S3_ENDPOINT_URL at their endpoint.

# Public files get a plain URL; private files get a signed URL with an
# expiry, so access is temporary. Here's the idea behind those URLs.

import hashlib

def file_url(bucket, key, private, expires_in=3600):
    base = f"https://{bucket}.s3.amazonaws.com/{key}"
    if not private:
        return base                      # public, cacheable by a CDN
    # Signed URLs carry a signature + expiry (boto3 does the real signing)
    signature = hashlib.sha256(f"{key}{expires_in}".encode()).hexdigest()[:12]
    return f"{base}?Expires={expires_in}&Signature={signature}"

print("Public  static:", file_url("my-app-assets", "static/app.css", private=False))
print("Private media: ", file_url("my-app-assets", "media/contract.pdf", private=True))

# ✅ Expected output:
# Public  static: https://my-app-assets.s3.amazonaws.com/static/app.css
# Private media:  https://my-app-assets.s3.amazonaws.com/media/contract.pdf?Expires=3600&Signature=73428821afee

🎯 Your Turn: Point the Backend at S3

Fill in the blank with the backend class string that django-storages provides for Amazon S3.

# 🎯 YOUR TURN: replace ___ with the S3 backend class name
# (hint: it is "S3Boto3Storage").

backend = "storages.backends.s3boto3.___"

STORAGES = {"default": {"BACKEND": backend}}
print("Media backend:", STORAGES["default"]["BACKEND"])

# EXPECTED OUTPUT:
# Media backend: storages.backends.s3boto3.S3Boto3Storage

Common Errors (And How to Fix Them)

❌ AccessDenied when uploading or reading objects

Your credentials or IAM policy don't allow the action on that bucket, or the keys are wrong.

✅ Fix: set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY from the environment (or an IAM role) and grant the user s3:PutObject and s3:GetObject on the bucket.

❌ Static files load locally but 404 in production

You configured the S3 staticfiles backend but never pushed the files to the bucket.

✅ Fix: run python manage.py collectstatic --noinput so the files are uploaded to s3://your-bucket/static/.

❌ Secrets leaked because they were hard-coded

AWS keys or the bucket name were written directly in settings.py and committed to git.

✅ Fix: read every credential from os.environ, keep them out of version control, and rotate any key that was exposed.

📋 Quick Reference

Setting / ToolWhat It Does
django-storages + boto3Provide the S3 backend and the AWS SDK it uses
STORAGESConfigure default (media) and staticfiles backends
S3Boto3StorageThe backend that reads/writes objects on S3
AWS_STORAGE_BUCKET_NAMEThe bucket files are stored in (from an env var)
collectstaticUpload collected static files into the bucket

🏆 Mini Challenge: Build the Asset URL

Combine the bucket name, the location prefix, and the file name to build the public URL Django would hand the browser — exactly what STATIC_URL + the storage backend produce.

# 🏆 Mini Challenge: build the public asset URL the way the S3 backend
# does, serving static from one prefix and media from another.

def asset_url(bucket, location, filename, cdn_domain=None):
    host = cdn_domain or f"{bucket}.s3.amazonaws.com"
    return f"https://{host}/{location}/{filename}"

bucket = "my-app-assets"

# Static served straight from S3
print(asset_url(bucket, "static", "css/app.css"))

# Media served through a CloudFront CDN domain
print(asset_url(bucket, "media", "avatars/u42.png", cdn_domain="cdn.example.com"))

# EXPECTED:
# https://my-app-assets.s3.amazonaws.com/static/css/app.css
# https://cdn.example.com/media/avatars/u42.png

❓ Frequently Asked Questions

Lesson complete — and you've finished the Django scaling track!

You can now serve static and user-uploaded media from cloud object storage with django-storages and boto3, configure the STORAGES setting with S3Boto3Storage backends, keep credentials in environment variables, push files with collectstatic, choose between public and signed URLs, and put a CloudFront CDN in front — even on S3-compatible stores like MinIO, R2, and Spaces.

🏆 You've completed the Django scaling track! From full-text search and Docker deployment to serving assets from S3 behind a CDN, you now have the production toolkit to ship Django apps that scale. Revisit any lesson whenever you need a refresher, and keep building.

Practice quiz

Which two packages let Django store files on Amazon S3?

  • django-storages and boto3
  • pillow and requests
  • whitenoise and gunicorn
  • psycopg2 and redis

Answer: django-storages and boto3. django-storages provides the storage backends and boto3 is the AWS SDK it uses to talk to S3.

Which Django setting (4.2+) configures storage backends for default and staticfiles?

  • FILE_BACKENDS
  • DJANGO_STORAGE
  • STORAGES
  • MEDIA_BACKENDS

Answer: STORAGES. The STORAGES setting, added in Django 4.2, holds a dict with default and staticfiles backend entries.

Which backend class does django-storages provide for S3?

  • BucketStorage
  • AmazonStorage
  • S3Boto3Storage
  • CloudStorage

Answer: S3Boto3Storage. S3Boto3Storage from storages.backends.s3boto3 is the backend that reads and writes objects on S3.

How should AWS credentials be supplied to your settings?

  • Pasted into the template
  • Read from environment variables
  • Hard-coded directly in settings.py
  • Committed to git for convenience

Answer: Read from environment variables. Credentials should come from environment variables (or an IAM role), never hard-coded or committed to version control.

Which command uploads your collected static files to the S3 bucket?

  • python manage.py uploadstatic
  • python manage.py runserver
  • python manage.py migrate
  • python manage.py collectstatic

Answer: python manage.py collectstatic. With the staticfiles backend pointed at S3, collectstatic copies all static files straight into the bucket.

Why shouldn't you serve user-uploaded media from gunicorn in production?

  • The app server is for application logic, not for streaming static bytes at scale
  • gunicorn cannot read files
  • Media files are always private
  • It is impossible to configure

Answer: The app server is for application logic, not for streaming static bytes at scale. Object storage and a CDN serve files far more efficiently and let the WSGI app server focus on requests, not byte streaming.

What is a common reason to put CloudFront in front of an S3 bucket?

  • To compile Python
  • To cache files at edge locations for faster, cheaper delivery
  • To run database migrations
  • To replace gunicorn

Answer: To cache files at edge locations for faster, cheaper delivery. CloudFront is a CDN that caches your objects near users, reducing latency and offloading requests from S3.

How are private S3 objects served securely to authorized users?

  • By emailing the file
  • By disabling HTTPS
  • By making the whole bucket public
  • With time-limited signed (presigned) URLs

Answer: With time-limited signed (presigned) URLs. For private files, S3Boto3Storage can generate signed URLs that grant temporary, expiring access without exposing the object publicly.

Which setting names the bucket that django-storages writes to?

  • S3_TARGET
  • MEDIA_BUCKET_PATH
  • AWS_STORAGE_BUCKET_NAME
  • STATIC_BUCKET

Answer: AWS_STORAGE_BUCKET_NAME. AWS_STORAGE_BUCKET_NAME tells S3Boto3Storage which bucket to read from and write to.

django-storages with the S3 backend also works with which S3-compatible stores?

  • Only local disk
  • MinIO, Cloudflare R2, and DigitalOcean Spaces
  • Only Google Sheets
  • Only Amazon S3

Answer: MinIO, Cloudflare R2, and DigitalOcean Spaces. Because they expose the S3 API, MinIO, Cloudflare R2, and DigitalOcean Spaces work with the same backend by changing the endpoint URL.

Continue this course

Frequently asked questions

Why move static and media files to S3 instead of serving them from Django?

Django's app server (gunicorn or uvicorn) exists to run your Python code and respond to requests, not to stream image and CSS bytes at scale. In production with DEBUG=False, Django won't serve static files at all, and serving user-uploaded media through the app server ties up workers and doesn't survive a redeploy on ephemeral file systems. Cloud object storage like Amazon S3 is built for exactly this: durable, effectively unlimited, cheap storage that any number of servers can read from. You upload your files once, then let S3 (usually behind a CDN) deliver them, freeing your app server to handle real traffic.

What is the difference between static files and media files?

Static files are the assets you ship with your code: CSS, JavaScript, fonts, and images that are part of the project itself. You gather them with collectstatic and they change only when you deploy. Media files are user-uploaded content, such as profile pictures or document attachments, that arrive at runtime through forms and ImageField/FileField. In the STORAGES setting you typically configure two backends: the 'staticfiles' entry for your collected static assets and the 'default' entry, which handles media, so each can have its own bucket, prefix, and access rules.

How do I keep my AWS credentials out of my source code?

Never hard-code AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or the bucket name directly in settings.py, and never commit them to git. Read them from environment variables (for example with os.environ), which you set in your hosting platform, your docker-compose file, or a local .env that is git-ignored. Even better, when you run on AWS infrastructure such as ECS or EC2, attach an IAM role so boto3 picks up temporary credentials automatically and you store no long-lived secrets at all. The same rule applies to MEDIA_URL and region settings: configuration belongs in the environment, not the codebase.

Should my S3 objects be public or private, and where does a CDN fit in?

It depends on the content. Public static assets like CSS and site images can be world-readable so a CDN can cache them. Sensitive media, such as a user's private documents, should be private; S3Boto3Storage can then generate signed (presigned) URLs that grant temporary, expiring access only to authorized users. In front of the bucket you usually place a CDN like Amazon CloudFront, which caches objects at edge locations close to users for lower latency and cost, and lets you serve everything over your own domain via STATIC_URL and MEDIA_URL. Because the backend speaks the S3 API, the same setup works with S3-compatible stores such as MinIO, Cloudflare R2, and DigitalOcean Spaces by changing the endpoint URL.