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.
  • curl for header verification.
Prerequisites for CDN cache headers Content-hashed filenames, CDN-fronted storage, and CI invalidation credentials enable a correct cache-control split. hashed filenames CDN + storage CI invalidation creds Cache-control split ready immutable vs revalidate
Content-hashed filenames are the prerequisite that makes a year-long immutable cache safe — a new build is a new filename.

Exact Configuration

  1. 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"
    
  2. 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"
    
  3. 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"
    
  4. 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"
    
Cache-control split by asset type Hashed assets are immutable for a year, HTML revalidates, and freshness-sensitive files stay uncached, with a deploy invalidation. JS / CSS / images (hashed) max-age 1y, immutable *.html max-age 0, revalidate sitemap / search index → uncached Deploy invalidation HTML + index paths Instant + cached both at once
Long-cached assets and always-fresh HTML together give both a fast repeat visit and an instantly-visible deploy.

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
Three header validation checks A hashed asset is immutable, HTML revalidates, and post-deploy HTML references the new hash. Asset immutable 1y max-age HTML revalidates max-age 0 New hash live deploy visible
The post-deploy hash check is the proof that a release is actually reaching users rather than being masked by a cached HTML page.

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
CDN header pitfalls by class Invisible deploys and stale JS are freshness mistakes; broad invalidation and MIME errors are configuration mistakes. Freshness invisible deploy stale JS / search → revalidate + invalidate Configuration broad invalidation MIME errors → scope paths + types
Only HTML and index files ever need invalidating — hashed assets are immutable, so invalidating them wastes CDN budget.

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.