Fingerprints in Hugo: cache-busting for style.css without fighting the browser

Introduction

In the previous post, I described cache diagnostics for a static Hugo website running behind Cloudflare, Traefik and nginx.

After fixing the nginx configuration, static assets started receiving sensible cache headers. Fonts, images and video files could be cached for longer, and Cloudflare correctly respected the headers from the origin.

However, one important topic remained: my own CSS file.

A typical CSS link looks like this:

<link rel="stylesheet" href="/css/style.css">

The problem is that the address does not change, even if the file content changes.

If the browser caches /css/style.css for a long time, the user may still see the old styles after deployment. The server already has the new file, but the browser does not necessarily have to fetch it immediately.

The solution is fingerprinting.

The problem with a fixed URL

Let’s assume we have a file:

/css/style.css

and we set a long cache lifetime for it, for example 30 days or one year.

The header may look like this:

Cache-Control: max-age=31536000
Expires: ...

For the browser, this roughly means:

I can keep this file locally for a long time.
I do not have to ask the server on every visit.

This is good for performance, but risky for a file that changes often.

After deployment, the content of style.css may already be different, but the URL is still the same:

/css/style.css

The browser has no simple reason to immediately fetch the new version.

That is why files without versioning often get a shorter cache lifetime, for example:

expires 7d;

This is a reasonable compromise, but it does not fully solve the problem. A better approach is to make the URL change whenever the file content changes.

What does fingerprinting do?

Fingerprinting adds a hash of the file content to the file name.

Instead of:

/css/style.css

we get, for example:

/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css

If the CSS content changes, the hash changes as well. And if the hash changes, the URL changes too.

For the browser, these are two different files:

/css/style.aaaa.css
/css/style.bbbb.css

Thanks to that, we can set a very long cache lifetime, because the new CSS version will get a new address anyway.

This is classic cache-busting.

static/ vs assets/ in Hugo

In Hugo, two directories are important here:

static/
assets/

At first glance, they may look similar, but they do completely different things.

static/

The static/ directory is for files that Hugo should copy 1:1 to the output public/ directory.

Example:

static/favicon.ico
static/images/logo.webp
static/css/style.css

after the build becomes:

public/favicon.ico
public/images/logo.webp
public/css/style.css

Hugo does not process these files. It does not hash them, minify them or generate integrity attributes for them.

This is good for files that should simply be publicly available under a fixed path.

assets/

The assets/ directory is the entry point for Hugo Pipes, which is Hugo’s asset processing mechanism.

Files from assets/ can be loaded in templates with:

resources.Get

and then transformed, for example with:

fingerprint
minify
concat
toCSS

If we want to use fingerprinting for CSS, the file must be in assets/, not in static/.

So instead of:

static/css/style.css

we need:

assets/css/style.css

Moving style.css to assets/

The simplest migration looks like this:

mkdir -p assets/css
cp static/css/style.css assets/css/style.css

For testing, copying the file is enough. After confirming that everything works, it is worth removing the old version from static/ so that you do not maintain two copies of the same CSS file.

rm static/css/style.css

If the file stays in both static/ and assets/, the website may still work, but later it becomes easy to edit the wrong file — not the one actually used by the template.

Using resources.Get and fingerprint

Previously, the head.html partial could contain something like this:

<link rel="stylesheet" href="{{ "/css/style.css" | relURL }}">

After moving the file to assets/css/style.css, Hugo Pipes can be used:

{{ $style := resources.Get "css/style.css" | fingerprint }}
<link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ $style.Data.Integrity }}">

It is worth remembering that:

resources.Get "css/style.css"

looks for the file in:

assets/css/style.css

not in:

static/css/style.css

If the file is missing from assets/, Hugo will return an error similar to:

error calling fingerprint: <nil> can not be transformed

This means that resources.Get did not find the file, so fingerprint has nothing to transform.

Example head.html

A fragment of head.html may look like this:

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    {{ $description := .Description | default .Site.Params.description | default .Site.Params.subtitle }}
    {{ with $description }}
    <meta name="description" content="{{ . | plainify | htmlEscape }}">
    {{ end }}

    <title>{{ .Title }}</title>

    <link rel="stylesheet" href="{{ "/vendor/bootstrap/5.3.8/css/bootstrap.min.css" | relURL }}">

    {{ $style := resources.Get "css/style.css" | fingerprint }}
    <link rel="stylesheet" href="{{ $style.RelPermalink }}" integrity="{{ $style.Data.Integrity }}">

    <link rel="icon" type="image/x-icon" href="/favicon.ico">
    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
    <link rel="manifest" href="/site.webmanifest">
</head>

In this example, Bootstrap stays in static/, because its URL contains the version:

/vendor/bootstrap/5.3.8/css/bootstrap.min.css

This is also a form of cache-busting. If Bootstrap is updated to another version in the future, the path will change.

The fingerprint makes the most sense here for my own file:

style.css

because that is the file that changes most often.

Result after the Hugo build

After running:

hugo

the HTML contains a link like this:

<link rel="stylesheet" href="/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css" integrity="sha256-OmNsUw/lwwtR9ZORoOFCCSHTkTFCX7aU9oovNRsacxQ=">

This means that Hugo:

found the file in assets/css/style.css
calculated a hash of its content
generated a new URL with the hash
added the integrity attribute

The integrity attribute is SRI, or Subresource Integrity. It allows the browser to verify that the downloaded file has exactly the content expected by the HTML.

Checking the generated HTML

After the build, it is worth checking whether the HTML really points to the fingerprinted file:

grep -R "style.*css" public/index.html

Or after deployment:

curl -s https://logos.net.pl/ | grep 'style.*css'

Example result:

<link rel="stylesheet" href="/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css" integrity="sha256-OmNsUw/lwwtR9ZORoOFCCSHTkTFCX7aU9oovNRsacxQ=">

If you still see:

/css/style.css

then the template is still using the old link, or the build/deployment was not done with the current version of the files.

Long cache for fingerprinted CSS

After enabling fingerprinting for CSS, it is safe to set a long cache lifetime for CSS and JS files.

Example nginx configuration:

location ~* \.(?:css|js)$ {
  try_files $uri =404;
  expires 1y;
  add_header Cache-Control "public, immutable" always;
  access_log off;
}

Why does immutable make sense this time?

Because the URL contains a hash of the file content. If the content changes, the URL changes as well.

For the browser, this means:

This specific file at this specific URL will never change.
If the application needs a new version, it will receive a new URL.

This is safe for fingerprinted assets.

HTML should still have a short cache

One important detail: a long cache lifetime for CSS does not mean a long cache lifetime for HTML.

HTML is what contains the link to the current CSS file:

<link rel="stylesheet" href="/css/style.<hash>.css">

If HTML was cached too aggressively, the user could still receive an old page pointing to the old CSS file.

That is why HTML should have a short cache lifetime or no-cache.

Example nginx configuration:

location ~* \.(?:manifest|appcache|html?|xml|json)$ {
  expires -1;
  add_header Cache-Control "no-cache" always;
}

A more restrictive version is also common:

add_header Cache-Control "no-cache, no-store, must-revalidate" always;

For a static website, no-cache is often enough, because it allows the browser to store the file but forces it to check whether the version on the server has changed.

Testing headers after deployment

After deployment, it is worth checking the fingerprinted CSS:

curl -I https://logos.net.pl/css/style.3a636c530fe5c30b51f59391a0e1420921d39131425fb694f68a2f351b1a7314.css \
  | grep -Ei 'cache-control|expires|cf-cache-status|age'

For fingerprinted CSS, we want to see something like:

cache-control: public, immutable
expires: ...
cf-cache-status: HIT

or:

cache-control: public, max-age=31536000, immutable

Depending on the nginx configuration, the headers may look slightly different, but the three important things are:

long max-age
immutable
new URL when the file content changes

It is also worth checking HTML:

curl -I https://logos.net.pl/ \
  | grep -Ei 'cache-control|expires|cf-cache-status|age'

HTML should not be cached aggressively.

What about Bootstrap?

In this case, Bootstrap stayed under the path:

/vendor/bootstrap/5.3.8/css/bootstrap.min.css

This means that the library version is part of the URL.

If Bootstrap is updated in the future, the path may change, for example to:

/vendor/bootstrap/5.3.9/css/bootstrap.min.css

For the browser, this will be a new asset. That is why Bootstrap does not have to go through Hugo Pipes immediately, although technically it can also be fingerprinted.

The simplest rule is:

own CSS, changed often:
  fingerprint

vendor with a version in the path:
  can stay as static

favicons, images, downloadable files:
  usually static

Conclusions

Fingerprinting solves the classic CSS caching problem:

I want a long cache lifetime, but I do not want stale CSS after deployment.

Thanks to Hugo Pipes, this can be done without Node.js, npm, webpack or a whole frontend toolchain.

All that is needed is to:

move style.css from static/ to assets/
use resources.Get
add fingerprint
link the generated RelPermalink
set a long cache lifetime for fingerprinted assets
keep a short cache lifetime for HTML

The most important distinction in Hugo is this:

static/  -> copy 1:1 to public/
assets/  -> process through Hugo Pipes
public/  -> build output

After this change, deploying a new CSS file stops being a fight with the browser and Cloudflare.

A style change means a hash change. A hash change means a new URL. A new URL means the browser fetches a new file.

And that is exactly what cache-busting is about.