Serve Django Static Files Through CloudFront

Serve Django static files from CloudFront: S3 with origin access control or origin pull, hashed filenames, immutable caching, and CORS.

Problem statement

Serving CSS, JavaScript, fonts, and images from a CDN reduces latency for distant users and removes static traffic from your application servers. Amazon CloudFront is a common choice for Django apps hosted on AWS. The setup has several places to go wrong:

  • files cached for a year without hashed names, so users keep old CSS after a deploy
  • a public S3 bucket, when CloudFront could be the only reader
  • fonts blocked by the browser because of missing CORS headers
  • templates referencing new asset names before those files exist on the CDN
  • collectstatic taking many minutes because it checks every file against S3

This guide shows two working architectures: uploading static files to a private S3 bucket behind CloudFront (recommended when you already use S3), and letting CloudFront pull from your existing Nginx /static/ location (simplest when files are already served by your servers).

Static files through CloudFrontcollectstatichashed filenamesS3 bucketprivate, OACCloudFrontedge cacheBrowserimmutable cacheContent hashes make a one-year cache safe and invalidations unnecessary.

Quick answer

  1. Use manifest-based hashed filenames (ManifestStaticFilesStorage or S3ManifestStaticStorage), so every changed file gets a new URL.
  2. Option A: collectstatic uploads to a private S3 bucket; CloudFront reads it through Origin Access Control; set custom_domain to the CloudFront hostname.
  3. Option B: point CloudFront at your site's domain as origin for the /static/* path and set STATIC_URL to the CloudFront domain.
  4. Send Cache-Control: public, max-age=31536000, immutable for hashed files.
  5. Add a CloudFront response headers policy with CORS for fonts.
  6. Upload static files before switching the app to the new release.
  7. Verify with curl -I: hashed URL, X-Cache, and cache headers.

Step-by-step solution

1. Choose the architecture

Option A: S3 + CloudFrontOption B: CloudFront pulls from Nginx
Where files liveS3 bucketEach app server's STATIC_ROOT
Upload stepcollectstatic writes to S3normal collectstatic on servers
Works with ephemeral containersyesyes, if every container has the files
Moving partsbucket, OAC, IAMorigin config only
two ways to put CloudFront in frontA: S3 bucket origincollectstatic uploads to S3✓app servers never serve staticneeds django-storages and IAMB: your server as originNginx or WhiteNoise stays the source✓no storage backend changefirst request per file hits the serverB is the smaller change; A scales without touching app serversdjango-deployment.com
Both rely on hashed file names for long caching.

Both rely on hashed filenames; without them, a long cache lifetime serves stale files.

2. Option A: S3 bucket and CloudFront

Create a bucket with Block Public Access enabled. In CloudFront, create a distribution:

  • Origin: the S3 bucket, with Origin access control (OAC) enabled.
  • Viewer protocol policy: Redirect HTTP to HTTPS.
  • Cache policy: CachingOptimized (respects origin cache headers within its bounds).
  • Compression: enabled.

CloudFront offers to update the bucket policy to allow the distribution. The resulting policy grants s3:GetObject to the CloudFront service principal, conditioned on your distribution's ARN, so only this distribution can read the bucket.

IAM permissions for the deploy role that runs collectstatic:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::myapp-static/*"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::myapp-static"
    }
  ]
}

The web application's runtime role does not need write access for static files.

3. Option A: Django settings

pip install "django-storages[s3]"
STATIC_URL = "https://d1234abcd.cloudfront.net/static/"

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "storages.backends.s3.S3ManifestStaticStorage",
        "OPTIONS": {
            "bucket_name": "myapp-static",
            "region_name": "eu-west-1",
            "location": "static",
            "custom_domain": "d1234abcd.cloudfront.net",
            "querystring_auth": False,
            "default_acl": None,
            "file_overwrite": True,
            "object_parameters": {
                "CacheControl": "public, max-age=31536000, immutable",
            },
        },
    },
}
static files on S3, URLs on CloudFrontSTATIC_URL = "https://d1234abcd.cloudfront.net/static/""BACKEND": "storages.backends.s3.S3ManifestStaticStorage","custom_domain": "d1234abcd.cloudfront.net","default_acl":None,"CacheControl": "public, max-age=31536000,immutable",12341Hashed names, manifest stored in S3.2URLs point at CloudFront, not the bucket.3Bucket stays private; CloudFront reads via OAC.4Safe only because names are hashed.Media keeps its own storage; this only changes static filesdjango-deployment.com
collectstatic now needs AWS credentials in the build or release step.
  • S3ManifestStaticStorage combines Django's manifest hashing with S3 uploads. Templates using {% static "css/app.css" %} render URLs such as https://d1234abcd.cloudfront.net/static/css/app.3f2a1b9c.css.
  • custom_domain makes generated URLs point at CloudFront instead of S3.
  • querystring_auth=False avoids signed URLs, which would defeat caching.
  • file_overwrite=True is safe for static files because hashed names change when content changes.

Replace the CloudFront hostname with your own alias (for example static.example.com) once you attach a certificate in ACM (in us-east-1, as CloudFront requires) and a DNS record.

4. Option A: where the manifest lives

With manifest storage, Django reads staticfiles.json to map css/app.css to its hashed name. By default that manifest is stored in the same storage, so each process fetches it from S3 at startup. Since Django 4.2, you can keep the manifest in a local storage instead:

from django.core.files.storage import FileSystemStorage
from storages.backends.s3 import S3ManifestStaticStorage


class StaticStorage(S3ManifestStaticStorage):
    def __init__(self, *args, **kwargs):
        kwargs["manifest_storage"] = FileSystemStorage(location="/srv/myapp/manifest")
        super().__init__(*args, **kwargs)

Reference this class in STORAGES["staticfiles"]["BACKEND"]. The manifest is then written during collectstatic and shipped with the release, so the app does not depend on S3 at startup and always uses the manifest that matches its code.

5. Option B: CloudFront pulls from your server

If Nginx already serves /static/ from a manifest-hashed STATIC_ROOT:

location /static/ {
    alias /srv/myapp/staticfiles/;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Access-Control-Allow-Origin "https://www.example.com";
}
option B: the server stays the originBrowserrequests cdn.example.com/static/…CloudFront edgeserves from cache when it canOrigin: Nginx /static/only on a cache missSTATIC_ROOT on diskfilled by collectstaticSet STATIC_URL to the CloudFront domain; nothing else changesdjango-deployment.com
Restrict the origin path to CloudFront if you want to hide it.

Create a CloudFront distribution with your site's hostname as a custom origin (HTTPS only), and a cache behavior for /static/*. In Django:

STATIC_URL = "https://d1234abcd.cloudfront.net/static/"
STORAGES = {
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
    },
    # "default": ...
}

CloudFront fetches each file from Nginx on the first request per edge location and caches it. Restrict the origin's /static/ to CloudFront if you want all static traffic to go through the CDN; otherwise both paths keep working.

The immutable header above applies to everything under /static/. That is only correct if all files are referenced through {% static %} with manifest hashing; files referenced by fixed names (for example a robots.txt placed in static) need a shorter cache time.

6. CORS for fonts and module scripts

Browsers require CORS headers for web fonts and for <script type="module"> loaded from another origin. In CloudFront, attach a response headers policy to the static cache behavior, such as the managed CORS policy, or a custom one that allows your site origin. For Option A, S3 CORS configuration is not needed when CloudFront adds the headers.

Symptom when missing: fonts fail to load with a console error mentioning No 'Access-Control-Allow-Origin' header.

7. Deploy ordering

A new release references new hashed filenames. If the web servers switch before those files exist on the origin, pages load without styles. Order the release:

  1. Build and run collectstatic (Option A uploads to S3; Option B collects on every server or into the image).
  2. Switch the application to the new release.
  3. Keep old hashed files for at least one release, so pages rendered by old processes or cached HTML still find their assets.
static first, then code1collectstatic uploads new filesold files stay2Manifest updatednew hashes known3Deploy the apptemplates use new hashes4No invalidation needednames changed5Prune old files laterafter rollback windowUploading after the app deploy briefly serves pages with missing assetsdjango-deployment.com
Keeping old files is also what makes rollback instant.

With Option A, collectstatic does not delete old files unless you pass --clear, so previous hashes remain available by default.

8. collectstatic performance

For Option A, collectstatic compares local files with S3 objects, which can be slow for thousands of files. Common mitigations:

  • run it once per release in CI, not on every server
  • keep the STATICFILES_DIRS and installed apps lean (unused apps still contribute files)
  • avoid --clear, which deletes and re-uploads everything

9. Verify

# Rendered HTML references the CDN with hashed names
curl -s https://www.example.com/ | grep -o 'https://d1234abcd.cloudfront.net/static/[^"]*' | head -3

# Headers on a hashed asset (run twice to see the cache hit)
curl -sI https://d1234abcd.cloudfront.net/static/css/app.3f2a1b9c.css | grep -i -E "cache-control|x-cache|content-encoding"

# The bucket is not publicly readable (Option A)
curl -sI https://myapp-static.s3.eu-west-1.amazonaws.com/static/css/app.3f2a1b9c.css | head -1

# Font has CORS headers for your origin
curl -sI -H "Origin: https://www.example.com" https://d1234abcd.cloudfront.net/static/fonts/inter.8c1d2e.woff2 | grep -i access-control

Verification check:

  • asset URLs contain content hashes
  • the second request shows X-Cache: Hit from cloudfront
  • Cache-Control includes max-age=31536000 and immutable
  • the direct S3 URL returns 403
  • fonts include Access-Control-Allow-Origin

Rollback

Switch the application back to the previous release. Its hashed files are still in S3 or on the servers, so its pages load correctly. To stop using the CDN entirely, set STATIC_URL back to /static/ and serve from Nginx or WhiteNoise.

Explanation

A CDN is a cache, and caches need URLs that change when content changes. Django's manifest storage provides that by hashing file contents into filenames and rewriting references inside CSS. With content-addressed URLs, CloudFront can cache every static file for a year, invalidations become unnecessary, and deploys only need to ensure new files are uploaded before new HTML references them.

Edge cases / notes

Invalidations

With hashed names you should rarely invalidate. Unhashed files such as favicon.ico or manifest.webmanifest are better served from your origin with short cache times.

Admin and third-party app assets

Admin CSS and JavaScript go through the same storage and CDN. If the admin looks unstyled after enabling the CDN, check the browser console for 403 or CORS errors on those assets.

WhiteNoise plus CloudFront

WhiteNoise-served files already carry immutable cache headers for hashed names, so pointing CloudFront at the app server (Option B without Nginx) is a simple, effective setup on platforms without a web server.

Troubleshooting CloudFront static files

SymptomCauseFix
403 AccessDenied XML from CloudFrontbucket policy does not allow the distribution, or object key wrongre-apply OAC bucket policy; check location prefix matches the URL path
404 for a hashed filerelease switched before collectstatic finished, or file never uploadedrun collectstatic before switching; check the manifest contains the file
ValueError: Missing staticfiles manifest entry in Djangotemplate references a file not in the manifestrun collectstatic; check for typos in {% static %} paths
Admin unstyled, console shows CORS or MIME errorsresponse headers policy missing, or wrong Content-Type on uploadadd CORS policy; django-storages sets content types from file extensions, so check unusual extensions
New CSS not visiblefile not hashed (for example referenced by a hardcoded URL)use {% static %} everywhere; purge only that path
Responses not compressedcompression disabled or Content-Type not compressibleenable compression in the cache behavior
CloudFront problems and causesSymptomUsual cause403 AccessDenied XMLOAC or bucket policy missingFonts blocked by CORSno CORS headers on the originOld CSS after deployunhashed names cachedWrong Content-Typeobject metadata set at uploadcurl -I on the CloudFront URL shows X-Cache and the origin headersdjango-deployment.com
X-Cache: Miss from cloudfront means the edge fetched from the origin.

Checking what CloudFront returns

curl -sI https://d1234abcd.cloudfront.net/static/admin/css/base.3f2a1b9c.css \
  | grep -i -E "^HTTP|content-type|cache-control|x-cache|age|via"
  • X-Cache: Miss from cloudfront on the first request and Hit from cloudfront afterwards means caching works.
  • The Age header shows how long the object has been in the edge cache.
  • A Content-Type of binary/octet-stream means the object was uploaded without a proper type; re-upload it.

Costs and performance

CloudFront pricing combines data transfer out and requests. For static files:

  • enable compression to reduce transfer size for CSS, JavaScript, and SVG
  • use long cache lifetimes so repeat visitors and edge locations rarely go back to the origin
  • consider a price class that excludes regions where you have few users, if latency there is acceptable

Also serve modern image formats and appropriately sized images; unoptimized images usually dominate static transfer costs more than CSS and JavaScript.

Security headers on static responses

A response headers policy can also add X-Content-Type-Options: nosniff and Strict-Transport-Security to static responses. Since static files are served without Django, these headers are not added by SecurityMiddleware.

Next steps

FAQ

Do I need to invalidate CloudFront after deploying Django static files?

Not when filenames are hashed by manifest storage. Changed files get new URLs, so CloudFront fetches them as new objects. Invalidate only unhashed files.

Should my S3 static bucket be public for CloudFront?

No. Keep Block Public Access enabled and grant read access to your distribution with Origin Access Control.

Why are fonts blocked when served from CloudFront?

Fonts loaded from a different origin require CORS headers. Attach a CloudFront response headers policy that allows your site's origin on the static cache behavior.

Why is my site unstyled right after a deploy with a CDN?

The new HTML references hashed files that were not uploaded yet, or old files were deleted too early. Upload static files before switching the release, and keep previous files for at least one release.

Keep going