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
collectstatictaking 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).
Quick answer
- Use manifest-based hashed filenames (
ManifestStaticFilesStorageorS3ManifestStaticStorage), so every changed file gets a new URL. - Option A:
collectstaticuploads to a private S3 bucket; CloudFront reads it through Origin Access Control; setcustom_domainto the CloudFront hostname. - Option B: point CloudFront at your site's domain as origin for the
/static/*path and setSTATIC_URLto the CloudFront domain. - Send
Cache-Control: public, max-age=31536000, immutablefor hashed files. - Add a CloudFront response headers policy with CORS for fonts.
- Upload static files before switching the app to the new release.
- Verify with
curl -I: hashed URL,X-Cache, and cache headers.
Step-by-step solution
1. Choose the architecture
| Option A: S3 + CloudFront | Option B: CloudFront pulls from Nginx | |
|---|---|---|
| Where files live | S3 bucket | Each app server's STATIC_ROOT |
| Upload step | collectstatic writes to S3 | normal collectstatic on servers |
| Works with ephemeral containers | yes | yes, if every container has the files |
| Moving parts | bucket, OAC, IAM | origin config only |
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",
},
},
},
}
S3ManifestStaticStoragecombines Django's manifest hashing with S3 uploads. Templates using{% static "css/app.css" %}render URLs such ashttps://d1234abcd.cloudfront.net/static/css/app.3f2a1b9c.css.custom_domainmakes generated URLs point at CloudFront instead of S3.querystring_auth=Falseavoids signed URLs, which would defeat caching.file_overwrite=Trueis 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";
}
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:
- Build and run
collectstatic(Option A uploads to S3; Option B collects on every server or into the image). - Switch the application to the new release.
- Keep old hashed files for at least one release, so pages rendered by old processes or cached HTML still find their assets.
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_DIRSand 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-Controlincludesmax-age=31536000andimmutable- 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
| Symptom | Cause | Fix |
|---|---|---|
403 AccessDenied XML from CloudFront | bucket policy does not allow the distribution, or object key wrong | re-apply OAC bucket policy; check location prefix matches the URL path |
404 for a hashed file | release switched before collectstatic finished, or file never uploaded | run collectstatic before switching; check the manifest contains the file |
ValueError: Missing staticfiles manifest entry in Django | template references a file not in the manifest | run collectstatic; check for typos in {% static %} paths |
| Admin unstyled, console shows CORS or MIME errors | response headers policy missing, or wrong Content-Type on upload | add CORS policy; django-storages sets content types from file extensions, so check unusual extensions |
| New CSS not visible | file not hashed (for example referenced by a hardcoded URL) | use {% static %} everywhere; purge only that path |
| Responses not compressed | compression disabled or Content-Type not compressible | enable compression in the cache behavior |
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 cloudfronton the first request andHit from cloudfrontafterwards means caching works.- The
Ageheader shows how long the object has been in the edge cache. - A
Content-Typeofbinary/octet-streammeans 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
- Understand hashing and cache headers in Long-Lived Caching for Django Static Files.
- If files disappear after a change, see Django Static Files Not Loading in Production.
- Resolve manifest errors with Fix collectstatic Errors in Django Deployments.
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.