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.
- Kind
- Contract
- Domain
- Build & Delivery
- Applies at
public/- Status
- Stable
- Source
scripts/deploy.sh
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.*.cssand*.min.*.jsget 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
- 01Fingerprinted assets carry
public, max-age=31536000, immutable. - 02Every other file carries
public, max-age=0, must-revalidate. - 03The fingerprinted pass runs first, so new assets exist before any HTML that references them.
- 04The second, unfiltered pass carries
--deleteand removes keys the new build no longer contains. - 05The 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.
- 01
Immutable pass
--exclude '*' --include '*.min.*.css' --include '*.min.*.js'with a one-year, immutable header - 02
Revalidate pass
Every file, with
max-age=0, must-revalidate, and--delete - 03
Invalidate
/*clears edge copies of the unhashed files
| Files | Cache-Control |
|---|---|
*.min.<hash>.css, *.min.<hash>.js | public, max-age=31536000, immutable |
| Everything else | public, 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#
# 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:
{{ $theme := resources.Get "js/theme.js" | minify | fingerprint }}
Connections