Setting CDN Cache Headers for Portal Assets
The fastest asset is the one the browser never re-requests, so getting Cache-Control right on a portal’s static bundle is often the single largest improvement to perceived load time — and getting it wrong is how a deploy becomes invisible for an hour. This how-to sets an immutable, year-long lifetime on fingerprinted JavaScript, CSS, and images while forcing HTML to revalidate, then invalidates the CDN on deploy. It implements the edge-cache layer of Portal Performance & Caching within Developer Portal Architecture & Frameworks.
Prerequisites
- A built portal bundle whose JS/CSS/image filenames contain a content hash (the default for Backstage, Docusaurus, and MkDocs Material builds).
- Object storage (S3, GCS) fronted by a CDN (CloudFront, Fastly, Cloudflare) for asset delivery.
- CDN credentials in CI as
${CDN_TOKEN}/ an OIDC role, so a deploy can invalidate paths. curlfor header verification.
Exact Configuration
-
Upload fingerprinted assets as immutable. A hashed filename can never change contents, so it is safe to cache for a year and skip revalidation entirely.
# Requires aws-cli >= 2 aws s3 sync ./build s3://${ASSET_BUCKET}/ --delete \ --exclude "*.html" \ --cache-control "public, max-age=31536000, immutable" -
Upload HTML as always-revalidate. The HTML references the hashed assets, so it must be fetched fresh — otherwise a browser keeps loading last week’s bundle references.
# Requires aws-cli >= 2 aws s3 sync ./build s3://${ASSET_BUCKET}/ \ --exclude "*" --include "*.html" \ --cache-control "public, max-age=0, must-revalidate" -
Keep freshness-sensitive files uncached. The sitemap and any generated search index must not be cached long, or new pages stay invisible to crawlers and search.
# Requires aws-cli >= 2 aws s3 cp ./build/sitemap.xml s3://${ASSET_BUCKET}/sitemap.xml \ --cache-control "public, max-age=0, must-revalidate" -
Invalidate the CDN on deploy. Even with revalidating HTML, invalidate the HTML and index paths so the edge fetches the new version immediately rather than on its own TTL.
# Requires aws-cli >= 2 aws cloudfront create-invalidation \ --distribution-id ${CF_DISTRIBUTION_ID} \ --paths "/*.html" "/sitemap.xml" "/search_index.json"
Validation
# Requires curl
# 1. A hashed asset returns immutable, long max-age
curl -sI "${PORTAL_URL}/assets/main.4f3a1b.js" | grep -i cache-control
# Expected: cache-control: public, max-age=31536000, immutable
# 2. HTML returns a revalidating header
curl -sI "${PORTAL_URL}/index.html" | grep -i cache-control
# Expected: cache-control: public, max-age=0, must-revalidate
# 3. After a deploy, the HTML reflects the new asset references
curl -s "${PORTAL_URL}/index.html" | grep -o 'main\.[a-f0-9]*\.js'
# Expected: the new hash, not the previous build's
Edge Cases & Troubleshooting
| Symptom | Root Cause | Resolution |
|---|---|---|
| Deploy not visible for an hour | HTML cached long at the edge | Set HTML to max-age=0, must-revalidate and invalidate /*.html on deploy |
| Stale JS loaded after a release | Assets not fingerprinted, or immutable HTML | Confirm the build hashes filenames and HTML is not immutable |
| Search shows old pages | Search index cached | Serve search_index.json uncached and invalidate it on deploy |
| CDN bill spikes | Invalidating /* on every deploy |
Invalidate only HTML and index paths; hashed assets never need invalidation |
| CORS or MIME errors on assets | Storage not setting Content-Type |
Set correct content types on upload; a CDN forwards what storage returns |
Frequently Asked Questions
Why revalidate HTML instead of caching it briefly?
Because the HTML is the pointer to every other asset. A briefly-cached HTML page still serves stale asset references for its TTL, so a user can load a page that references JavaScript that no longer exists after a --delete sync, producing a broken app. must-revalidate with a deploy invalidation guarantees the HTML is always the current pointer.
Do I still need CDN invalidation if HTML revalidates?
Revalidation means the edge asks the origin “has this changed?” on each request, which is correct but adds a round-trip until the edge learns the new version. A targeted invalidation on deploy tells the edge immediately, so the first user after a release gets the new page without waiting for the edge’s own check. Invalidate the small set of freshness-critical paths, not everything.