Contract specification

Fingerprinted Cache Tiers

Content-hashed CSS and JS are cached for a year as immutable; everything else is revalidated on every request and kept fresh by invalidation.

Applies at
public/
Status
Stable

01 Defines

Two Cache-Control tiers for an uploaded static site, chosen by file name: fingerprinted assets whose URL changes with their content are immutable, and every other file must be revalidated.

02 Applies when

  • A static site is served from object storage through a CDN.
  • The site generator fingerprints CSS and JS into names like site.min.<hash>.css.
  • HTML and other unhashed files must reflect a new deployment promptly.

03 Boundaries

  • Only names matching *.min.*.css and *.min.*.js get the long tier; nothing else is assumed to be fingerprinted.
  • Headers are set at upload time; the CDN’s own invalidation is a separate step.
  • Only the unfiltered upload pass deletes stale objects.

Expected behaviour

  1. 01
    Fingerprinted assets carry public, max-age=31536000, immutable.
  2. 02
    Every other file carries public, max-age=0, must-revalidate.
  3. 03
    The fingerprinted pass runs first, so new assets exist before any HTML that references them.
  4. 04
    The second, unfiltered pass carries --delete and removes keys the new build no longer contains.
  5. 05
    The whole distribution is invalidated with /* after the upload.

Behaviour#

A fingerprinted file’s URL is derived from its content, so a given URL always means the same bytes and can be cached forever. An HTML page’s URL stays the same while its content changes, so it must be checked on every request. The upload encodes that difference in two passes over the same public/ tree.

FlowTwo upload passes
  1. 01 Immutable pass --exclude '*' --include '*.min.*.css' --include '*.min.*.js' with a one-year, immutable header
  2. 02 Revalidate pass Every file, with max-age=0, must-revalidate, and --delete
  3. 03 Invalidate /* clears edge copies of the unhashed files
FilesCache-Control
*.min.<hash>.css, *.min.<hash>.jspublic, max-age=31536000, immutable
Everything elsepublic, max-age=0, must-revalidate

Files the first pass has just uploaded are already identical at the destination, so the second pass skips them and they keep their long header. Its job is to give every remaining file the short header and to prune keys the new build no longer produces.

The filtered --delete trap#

What the pattern relies on#

The long tier is only safe because the site generator renames a file whenever its content changes. The pattern trusts the name: a file that is fingerprinted under a different naming scheme falls into the revalidate tier, which is safe but forgoes long caching. A file that matches the pattern but is not content-hashed would be cached for a year with no way to update it.

Invalidation with /* covers the other direction: without it, an edge could keep serving an old page that references the previous build’s assets. CloudFront bills a /* invalidation as a single path.

Examples#

scripts/deploy.sh
# Hugo fingerprints CSS and JS as name.min.<hash>.ext, so those are safe to cache
# forever. Everything else is revalidated, and only the unfiltered pass may carry
# --delete: filters apply to the destination too, so a filtered delete would
# remove every key the filter excluded.
aws s3 sync public/ "s3://$bucket/" \
	--cache-control 'public, max-age=31536000, immutable' \
	--exclude '*' --include '*.min.*.css' --include '*.min.*.js'
aws s3 sync public/ "s3://$bucket/" --delete \
	--cache-control 'public, max-age=0, must-revalidate'

aws cloudfront create-invalidation --distribution-id "$distribution" --paths '/*' > /dev/null

In a Hugo template, the fingerprinted name comes from piping a resource through minify and fingerprint:

www/specifications/layouts/_partials/head.html
{{ $theme := resources.Get "js/theme.js" | minify | fingerprint }}