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:DeleteObject and cloudfront:CreateInvalidation, ideally assumed through OIDC as in Securing Deploy Credentials with GitHub OIDC.

How the Three Layers Interact

How S3 metadata, the CloudFront cache policy and the browser interact Four actors, the browser, CloudFront, the cache policy and S3. CloudFront requests the object from S3, which returns it with the Cache-Control set at upload. The cache policy clamps CloudFront's own TTL between its minimum and maximum. CloudFront stores the object for that time and sends the original Cache-Control on to the browser, which caches according to max-age. Who decides how long a file is cached Browser CloudFront Cache policy S3 GET /guide/ fetch on miss object + Cache-Control metadata clamp TTL: min ≤ s-maxage ≤ max object + original Cache-Control
Browsers always see the S3 metadata unless a response headers policy overrides it; CloudFront's own TTL is the header clamped by the policy.

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.

Number of invalidation paths per deploy over one month Horizontal bars of deploy types over a month. Content edits, 34 deploys averaging 12 paths each. Navigation or template changes, 3 deploys using a single wildcard. Asset-only changes, 5 deploys with 0 paths because hashed assets need no invalidation. Paths invalidated per deploy Content edits (avg paths) 12 Template changes (wildcard) 1 Asset-only changes 0
Total for the month was 411 paths, within the free allowance; before, every deploy invalidated slash star.

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

MeasureBeforeAfterTool
HTML edge hit ratio61%93%CloudFront reports
TTFB p75, HTML, readers outside EU310 ms190 msRUM
Repeat-visit requests (second page)223WebPageTest
Invalidation paths / month~42 × /*411 targetedAWS billing
Stale 404s after publishing a pageup to 24 h60 sManual test
HTML hit ratio, TTFB and repeat requests before and after Paired bars. HTML edge hit ratio went from 61 to 93 percent. TTFB at the 75th percentile for readers outside the EU went from 310 to 190 milliseconds. Requests on a repeat visit went from 22 to 3. Edge and browser results Before After HTML hit ratio (%) 61 93 TTFB p75 non-EU (ms) 310 190 Repeat-visit requests 22 3
CloudFront reports and RUM over two weeks before and after; repeat visit measured with WebPageTest.

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-control in the sync script only. Existing objects keep old metadata; copy them in place once.
  • --metadata-directive REPLACE without --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.

Filed underHow-tosAWS (S3 and CloudFront)