Cache-Control Headers on S3 and CloudFront
S3 with CloudFront is still one of the most common ways to host a static site, especially in organisations already on AWS. Its caching has three places to configure — object metadata in S3, the distribution's cache policy, and optionally a response headers policy — and they interact in ways that are easy to misread. The most frequent result is one of two problems: everything cached with the same headers (so HTML goes stale or assets revalidate constantly), or headers set correctly once and lost on the next deploy because the upload command did not reapply them.
This guide sets up caching for an Eleventy documentation site on S3 and CloudFront, using upload metadata for browser caching, a cache policy for edge caching, and targeted invalidations, then measures TTFB and repeat-visit performance. It belongs to CDN Caching Rules for SSGs; the base deployment is in Deploying a Static Site to S3 and CloudFront.
Prerequisites
- A private S3 bucket behind a CloudFront distribution with an origin access control.
- Hashed filenames for CSS, JavaScript and processed images.
- An IAM role for CI with
s3:PutObject,s3:DeleteObjectandcloudfront:CreateInvalidation, ideally assumed through OIDC as in Securing Deploy Credentials with GitHub OIDC.
How the Three Layers Interact
Step 1: Upload With the Right Metadata
aws s3 sync applies --cache-control to every file it uploads, so two passes give two policies:
# Hashed assets: a year, immutable
aws s3 sync _site/ "s3://$BUCKET/" --delete \
--exclude '*' --include 'assets/*' --include 'img/*' \
--cache-control 'public, max-age=31536000, immutable'
# Everything else (HTML, feeds, sitemaps): browsers revalidate, edge keeps a day
aws s3 sync _site/ "s3://$BUCKET/" --delete \
--exclude 'assets/*' --exclude 'img/*' \
--cache-control 'public, max-age=0, s-maxage=86400, must-revalidate'
A subtle problem: sync uploads only files that changed, by size and timestamp. Changing the --cache-control value in the script does not update files that have not changed, so after a policy change most objects keep the old metadata. Fixing existing objects needs a one-off copy-in-place:
aws s3 cp "s3://$BUCKET/" "s3://$BUCKET/" --recursive --exclude '*' --include '*.html' \
--metadata-directive REPLACE --content-type text/html --cache-control 'public, max-age=0, s-maxage=86400, must-revalidate'
--metadata-directive REPLACE requires restating Content-Type, or S3 sets it to binary/octet-stream and browsers download the HTML instead of displaying it — which is exactly what happened on the first attempt, for 40 seconds on a staging bucket.
Step 2: A Cache Policy That Respects the Headers
The distribution used the managed CachingOptimized policy, which has a minimum TTL of 1 second, default 1 day and maximum 1 year, and excludes query strings and cookies from the cache key. For this site that was right: CloudFront honours s-maxage=86400 for HTML and max-age=31536000 for assets. A custom policy is needed only to change the key, for example to include a query string a page depends on. Cache Keys and Query Strings on CDNs covers when that is necessary.
The earlier configuration had used the legacy "use origin cache headers" setting with a minimum TTL of 86,400, set by someone trying to improve the hit ratio. That forced CloudFront to keep even 404 responses for a day.
Step 3: Invalidate Only What Changed
Each deploy compares the new build with a manifest of the previous one and invalidates only changed HTML paths:
node scripts/changed-paths.mjs prev-manifest.json _site > changed.txt # e.g. /guide/install/ /index.html
[ -s changed.txt ] && aws cloudfront create-invalidation --distribution-id "$DIST" \
--paths $(head -n 3000 changed.txt | tr '\n' ' ')
Directory-style URLs need both forms invalidated (/guide/install/ and /guide/install/index.html) if readers can reach both. Typical deploys changed 5–40 pages; a template change that touched every page falls back to /*. The first 1,000 invalidation paths a month are free.
Step 4: Override at the Edge When Needed
Some objects are uploaded by other tools — a CMS export writing PDFs, a separate team's widget bundle — with no Cache-Control metadata at all. A response headers policy can add or override headers at the edge for specific path patterns without touching the objects:
{
"CustomHeadersConfig": { "Items": [
{ "Header": "Cache-Control", "Value": "public, max-age=86400", "Override": false }
]}
}
Override: false adds the header only when the origin did not send one, which makes it a safe default for a cache behaviour on /downloads/*.
Keeping Metadata Right Over Time
Metadata drifts because uploads come from more than one place. Besides CI, two scripts and one person's laptop had written to the bucket over the years. A weekly job lists objects with aws s3api list-objects-v2, samples their metadata with head-object, and reports any HTML without max-age=0 and any hashed asset without immutable. Its first run found 1,140 HTML objects with no Cache-Control at all — uploaded by an old script that never set it — which CloudFront had been caching for its default TTL of one day and browsers had been caching heuristically for an unpredictable time. The bucket policy now denies PutObject to every principal except the CI role, so the deploy is the only writer.
Compression
CloudFront compresses text responses itself when the cache policy enables Gzip and Brotli, so there is no need to upload precompressed files to S3 for this setup. Uploading precompressed .br files with Content-Encoding metadata works too, and allows maximum Brotli settings, but complicates the sync and breaks for clients that do not accept Brotli. On this site, CloudFront's on-the-fly Brotli produced files about 4% larger than brotli -q 11, which was not worth the complexity.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| HTML edge hit ratio | 61% | 93% | CloudFront reports |
| TTFB p75, HTML, readers outside EU | 310 ms | 190 ms | RUM |
| Repeat-visit requests (second page) | 22 | 3 | WebPageTest |
| Invalidation paths / month | ~42 × /* | 411 targeted | AWS billing |
| Stale 404s after publishing a page | up to 24 h | 60 s | Manual test |
The hit ratio rose because targeted invalidations stopped clearing the whole HTML cache on every deploy. The repeat-visit change came from the assets finally carrying immutable.
Pitfalls & Rollback
- Changing
--cache-controlin the sync script only. Existing objects keep old metadata; copy them in place once. --metadata-directive REPLACEwithout--content-type. HTML becomes a download.- High minimum TTLs in the cache policy. They also cache errors.
- Invalidating
/*on every deploy. It empties the HTML cache and adds cost at high deploy rates. - Rollback: metadata changes are reversible with the same copy-in-place command; switching the distribution back to its previous cache policy is a single setting.
Conclusion
On S3 and CloudFront, browser caching comes from object metadata set at upload — two sync passes, one per policy — while CloudFront's own caching follows those headers within its cache policy. Restating metadata on existing objects, removing an over-eager minimum TTL and invalidating only changed pages raised the HTML hit ratio from 61% to 93% and cut a repeat visit from 22 requests to 3.
FAQ
Where should Cache-Control be set for a site on S3 and CloudFront?
As object metadata in S3, set when files are uploaded, because S3 returns it with each object and CloudFront passes it through to browsers. CloudFront's cache policy then decides how long CloudFront itself keeps objects, using the origin headers within its minimum and maximum TTLs. A response headers policy can override Cache-Control at the edge when changing metadata is impractical.
Why do my CloudFront files ignore the Cache-Control I set?
The cache policy's minimum TTL may be higher than the header's max-age, or its maximum TTL lower, in which case CloudFront clamps its own caching. Browsers still receive the original header. Also check that the upload actually set the metadata; aws s3 sync applies cache-control only to files it uploads in that run, not to unchanged ones.
Do I need to invalidate CloudFront after every deploy?
For HTML, yes, unless HTML has a short edge TTL. Hashed assets have new names on change and need no invalidation. Invalidate only the changed HTML paths, or a small set of wildcards, rather than slash star on every deploy, to keep cached pages valid and stay within the free invalidation allowance.
Should HTML be cached at the CloudFront edge?
Yes, with an invalidation on deploy or a short TTL with stale-while-revalidate. Caching HTML at the edge avoids an S3 round trip on most requests, which lowered TTFB by about 120 milliseconds for distant readers in the measurements in this guide.
Related
- Parent: CDN Caching Rules for SSGs — caching across hosts.
- Deploying a Static Site to S3 and CloudFront — the base setup.
- Cloudflare vs CloudFront Cache Hit Ratios for Static Sites — tuning results compared.
- Rolling Back S3 and CloudFront Deploys With Versioned Prefixes — deploys that can be reversed.
- CloudFront Functions for Redirects — the other edge logic on this setup.
Filed underHow-tosAWS (S3 and CloudFront)