Django: Serving Static & Media Files from Amazon S3 and CloudFront CDN for Faster Loading

Blog / Django · March 23, 2022 · Updated June 10, 2026 · 10 min read
Django: Serving Static & Media Files from Amazon S3 and CloudFront CDN for Faster Loading

Serving Django's static assets (CSS, JS, fonts) and user-uploaded media from Amazon S3 behind Amazon CloudFront is the standard way to make a production Django site load fast worldwide. S3 gives you durable, scalable object storage; CloudFront caches those objects at edge locations close to your users; and your application server stops wasting CPU and bandwidth streaming files it should never have to touch.

The modern wiring uses the django-storages library with boto3, configured through Django's STORAGES setting (Django 4.2+ / 5.x) — not the legacy DEFAULT_FILE_STORAGE / STATICFILES_STORAGE strings. This guide walks through a current, secure setup: a least-privilege IAM identity, a locked-down bucket, CloudFront with Origin Access Control (OAC), hashed long-cache static files, and signed URLs for private media.

Why serve Django files from S3 + CloudFront

By default Django stores uploads on the local filesystem and collectstatic writes static files to a local directory served by your WSGI/ASGI app (or WhiteNoise). That works for a single small server, but it doesn't scale:

  • Offload the app server. Gunicorn/Uvicorn workers should run your views, not stream megabytes of images and bundles. Moving files off the box frees CPU, memory, and connections.
  • Global edge caching. CloudFront serves cached objects from 600+ edge locations, so a user in Sydney or London gets your assets from a nearby PoP instead of your single origin region.
  • Durability and horizontal scaling. S3 stores 11 nines of durability and is shared across every app instance, so you can autoscale or run multiple servers without files going missing.
  • Better Core Web Vitals. Long-lived cache headers, HTTP/2 + HTTP/3, and Brotli/gzip at the edge improve LCP and reduce bandwidth costs.

Step 1: Create the S3 bucket and a least-privilege IAM identity

Create one bucket per environment (for example my-app-prod and my-app-staging) in the AWS region closest to your origin server. Two settings matter most:

  • Block all public access: ON. With CloudFront + OAC in front, the bucket itself never needs to be public. CloudFront reads from it; the public reads from CloudFront.
  • Object Ownership: Bucket owner enforced (ACLs disabled). This is the default for buckets created since 2023. It means object ACLs like public-read no longer apply — which is why modern django-storages configs set default_acl = None.

For credentials, prefer an IAM role attached to your compute (EC2 instance profile, ECS task role, or EKS IRSA) so no keys ever live in code or env files. boto3 picks the role up automatically. Only fall back to an IAM user's access keys for local development, and scope the policy tightly to the one bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AppObjectAccess",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-app-prod/*"
    },
    {
      "Sid": "AppBucketList",
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::my-app-prod"
    }
  ]
}

Step 2: Install django-storages and boto3

django-storages is the storage backend; boto3 is the AWS SDK it talks to. The [s3] extra pulls in the right boto3 version:

pip install "django-storages[s3]"
# (django-storages also installs boto3 via the [s3] extra)

Then add storages to INSTALLED_APPS:

# settings.py
INSTALLED_APPS = [
    # ...
    "storages",
    # ...
]

Step 3: Configure the modern STORAGES setting

On Django 4.2+ (and 5.x), define both backends in a single STORAGES dict. Use separate prefixes for media and static via location, sign media URLs but not static, and serve static through your CloudFront domain with custom_domain:

# settings.py  (Django 4.2+ / 5.x)
AWS_S3_REGION_NAME = "ap-south-1"
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=2592000"}  # 30 days
# Do NOT set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in production:
# leave them unset so boto3 uses the attached IAM role.

CLOUDFRONT_DOMAIN = "d1234abcdwxyz.cloudfront.net"  # or cdn.example.com

STORAGES = {
    # Media: user uploads -> private prefix, time-limited signed URLs
    "default": {
        "BACKEND": "storages.backends.s3.S3Storage",
        "OPTIONS": {
            "bucket_name": "my-app-prod",
            "location": "media",
            "default_acl": None,          # ACLs disabled on modern buckets
            "querystring_auth": True,     # presigned, expiring URLs
            "file_overwrite": False,      # never clobber two files of same name
            "signature_version": "s3v4",
        },
    },
    # Static: collectstatic output -> hashed names, served via CloudFront
    "staticfiles": {
        "BACKEND": "storages.backends.s3.S3ManifestStaticStorage",
        "OPTIONS": {
            "bucket_name": "my-app-prod",
            "location": "static",
            "default_acl": None,
            "querystring_auth": False,    # public, no signature needed
            "custom_domain": CLOUDFRONT_DOMAIN,  # URLs -> the CDN
        },
    },
}

STATIC_URL = "static/"   # must end in a slash; custom_domain must NOT

A few things worth calling out:

  • S3ManifestStaticStorage is the S3 equivalent of Django's ManifestStaticFilesStorage: collectstatic writes content-hashed filenames (app.4f3a9c.css) and a staticfiles.json manifest, so you can cache static assets effectively forever and bust the cache automatically on every deploy.
  • Setting custom_domain only on the static backend keeps static URLs on CloudFront while media URLs stay as presigned S3 URLs — important, because S3 presigned signatures don't pass through a CloudFront OAC distribution (use CloudFront signed URLs for private media via the CDN; see Step 6).
  • default_acl = None is correct for buckets with ACLs disabled — don't copy old tutorials that set "public-read".

The legacy settings (still supported, but avoid). Before Django 4.2 you configured storage with standalone strings. These still work for backwards compatibility, but don't use them in new projects — and never mix them with STORAGES (Django will raise an error if you define both):

# settings.py  (LEGACY — pre-4.2 style, shown for reference only)
DEFAULT_FILE_STORAGE = "storages.backends.s3.S3Storage"
STATICFILES_STORAGE = "storages.backends.s3.S3ManifestStaticStorage"
# The old class path "storages.backends.s3boto3.S3Boto3Storage"
# still resolves as an alias, but "storages.backends.s3.S3Storage"
# is the current name.

Step 4: Put CloudFront (with OAC) in front of the bucket

Create a CloudFront distribution with the S3 bucket as its origin and attach an Origin Access Control (OAC) — the current replacement for the deprecated Origin Access Identity (OAI). OAC lets CloudFront sign its requests to S3 with SigV4 so the bucket can stay fully private.

In the CloudFront console:

  1. Origin: select your bucket and choose Origin access control settings (recommended); create an OAC.
  2. CloudFront generates a bucket policy snippet — paste it into the bucket so it allows s3:GetObject only from this distribution's service principal.
  3. Viewer protocol policy: Redirect HTTP to HTTPS.
  4. Compression: enable Automatically compress objects (Brotli + gzip at the edge).
  5. (Optional) add an alternate domain name like cdn.example.com with an ACM certificate, and point CLOUDFRONT_DOMAIN at it instead of the *.cloudfront.net name.

With custom_domain set (Step 3), {% static '...' %} and collectstatic already emit https://<cloudfront-domain>/static/... URLs, so your templates need no changes.

Step 5: Long cache, hashed filenames, and deploy invalidations

Because S3ManifestStaticStorage hashes static filenames, a changed file gets a brand-new URL — so you can safely cache static assets at the edge for a year and let new deploys bust themselves. Set a long Cache-Control on the static prefix and a shorter one on media if it changes often.

Run collectstatic as part of every deploy to upload new hashed files:

# during deploy
python manage.py collectstatic --noinput

# Invalidate CloudFront only for paths that reuse the same URL
# (hashed static files don't need this; do it for unhashed assets).
aws cloudfront create-invalidation \
  --distribution-id E123ABC456DEF \
  --paths "/static/index.html" "/media/avatars/*"

Keep invalidations targeted: CloudFront gives a monthly free allotment of invalidation paths and charges per path beyond it (usage-based; verify current figures on aws.amazon.com). With content-hashed static files you rarely need invalidations at all — the URL itself changes, so the edge simply fetches the new object.

Step 6: Private media, signed URLs, CORS, and compression

Not every upload should be world-readable (think invoices, ID documents, paid downloads). With querystring_auth = True (Step 3), django-storages returns time-limited presigned S3 URLs straight from the bucket — set how long they last with AWS_QUERYSTRING_EXPIRE (seconds):

# settings.py
AWS_QUERYSTRING_EXPIRE = 600  # private media links valid for 10 minutes

If you want private media to flow through CloudFront instead of directly from S3, use CloudFront signed URLs: restrict viewer access on the distribution, create a CloudFront key pair, and give django-storages the key so it signs CDN URLs:

# settings.py  (private media via CloudFront signed URLs)
AWS_CLOUDFRONT_KEY = open("/etc/secrets/cf_private_key.pem", "rb").read()
AWS_CLOUDFRONT_KEY_ID = "K1A2B3C4D5E6F7"  # CloudFront public key ID

Other production hardening:

  • HTTPS everywhere — CloudFront redirects HTTP to HTTPS, and django-storages builds https:// URLs by default.
  • CORS for web fonts and cross-origin XHR. Add a CORS rule on the bucket allowing GET from your site origin so browsers will load .woff2 fonts served from the CDN.
  • Compression — let CloudFront compress text assets at the edge; you generally don't need django-storages' gzip option once CloudFront compression is on.
  • One bucket (or prefix) per environment so staging can never read or overwrite production objects.

WhiteNoise vs S3 vs S3 + CloudFront

There's no single right answer — match the approach to the app:

Approach Best for Media uploads Global edge cache App-server load
WhiteNoise (serve from the app) Small or static-only apps, MVPs, internal tools No — static files only No (unless you add a CDN in front) App streams the files
S3 only Apps needing durable, shared media storage in one region Yes No — single region Offloaded to S3
S3 + CloudFront Production apps with a global or growing audience Yes (public or signed) Yes — 600+ edge PoPs Offloaded + edge-cached

WhiteNoise is genuinely fine for small static-only sites and is simpler to operate — it compresses and hashes static files inside your app with zero AWS setup. Reach for S3 the moment you accept user uploads or run more than one server, and add CloudFront when latency for distant users or origin bandwidth starts to matter.

Cost and performance notes

Both S3 and CloudFront are usage-based: you pay for storage, requests, and data transfer out, and AWS offers a free tier for new accounts. Two practical levers keep the bill low and the site fast:

  • Cache aggressively at the edge. A high CloudFront cache-hit ratio means most requests never reach S3, cutting both S3 request costs and origin transfer. Hashed static filenames + long Cache-Control do most of this for you.
  • Serve user traffic from CloudFront, not S3 directly. Data transfer from CloudFront to viewers is typically cheaper than direct S3-to-internet transfer, and far faster.

Specific AWS prices change over time — treat any figure here as indicative and confirm current rates on aws.amazon.com. (This article doesn't quote MicroPyramid's fees; AWS billing is separate and goes directly to your AWS account.)

Getting it right in production

A clean S3 + CloudFront setup is straightforward to wire up but easy to get subtly wrong — public buckets left open, broken presigned media, missing CORS headers on fonts, or cache invalidations that quietly run up costs. At MicroPyramid we've spent 12+ years and 50+ delivered projects building and operating Django applications on AWS, so we tend to catch these before they reach production.

If you'd like help, our teams cover the full path: Django development services for the application itself, AWS consulting services to design the S3/CloudFront/IAM architecture, cloud migration services to move existing assets onto S3 safely, and server maintenance services to keep distributions, certificates, and invalidations healthy over time.

Frequently Asked Questions

Do I need CloudFront, or is S3 enough?

S3 alone durably stores and serves your files, but it serves them from a single region with no edge caching. If your users are spread across regions, or you want lower latency and cheaper egress, put CloudFront in front of S3. For a small audience close to your S3 region, S3-only can be acceptable to start.

Should I use WhiteNoise or S3 for Django static files?

Use WhiteNoise for small or static-only apps where you don't accept uploads and run a single server — it needs no AWS setup and compresses/hashes files inside your app. Switch to S3 (ideally with CloudFront) once you have user uploads, multiple app servers, or a global audience that benefits from edge caching.

How do I serve private user uploads securely?

Keep default_acl = None and querystring_auth = True so django-storages generates time-limited presigned S3 URLs, and tune AWS_QUERYSTRING_EXPIRE. To serve private media through the CDN instead, restrict viewer access on the CloudFront distribution and configure AWS_CLOUDFRONT_KEY / AWS_CLOUDFRONT_KEY_ID so URLs are CloudFront-signed.

Why are my static files not cache-busting after a deploy?

You're likely using a non-hashing static backend, so the filename (and URL) never changes and the edge keeps serving the old cached object. Use S3ManifestStaticStorage, which gives each file a content hash in its name; new deploys produce new URLs that bypass the cache automatically.

Do I have to hardcode AWS keys in settings.py?

No — and you shouldn't in production. Attach an IAM role to your compute (EC2 instance profile, ECS task role, or EKS IRSA) and leave AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY unset so boto3 uses the role automatically. Reserve access keys for local development only, scoped to a single bucket.

Is the old DEFAULT_FILE_STORAGE setting still supported?

Yes, DEFAULT_FILE_STORAGE and STATICFILES_STORAGE still work for backwards compatibility, but on Django 4.2+ you should use the unified STORAGES dict instead. Define one or the other — Django raises an error if you configure both at the same time.

Share this article