Why did Lighthouse show a 4h cache? Debugging nginx and Cloudflare

Introduction

While polishing a static website built with Hugo, I ran Lighthouse to check whether there were still any simple things worth improving.

One of the warnings was related to a short cache lifetime for static assets. Lighthouse listed CSS files, fonts, images and a video file, which according to the report were cached for only about 4 hours.

At first glance, the topic looked simple: set a longer cache lifetime and be done with it. In practice, there were several layers involved:

user's browser
Cloudflare
Traefik
nginx inside a container
static Hugo website

And as usual with several layers — it is better to first check what is really happening instead of assuming that “it must be Cloudflare”.

Symptom

Lighthouse reported a caching issue for static files, including:

/css/style.css
/vendor/bootstrap/.../bootstrap.min.css
/fonts/webfonts/JetBrainsMono-Regular.woff2
/fonts/webfonts/roboto.regular.ttf
/images/logo_logos.webp
/images/terminal.mp4

The report showed a cache lifetime of around 4 hours.

Since the website runs behind Cloudflare, the first thought was obvious: Cloudflare has Browser Cache TTL set to 4 hours and is overriding the headers from the origin.

But before changing anything in Cloudflare, I checked the origin directly.

Checking headers without Cloudflare

First, I checked the response directly from nginx running inside the container:

curl -I http://192.168.1.254:1313/fonts/webfonts/roboto.regular.ttf

The response looked more or less like this:

HTTP/1.1 200 OK
Server: nginx/1.30.3
Content-Type: font/ttf
Content-Length: 126072
Last-Modified: Fri, 26 Jun 2026 08:29:17 GMT
ETag: "6a3e385d-1ec78"
Accept-Ranges: bytes

The most important headers were missing:

Cache-Control: ...
Expires: ...

That immediately narrowed down the problem. If nginx was not returning cache headers, Cloudflare had nothing useful to respect. The problem had to be investigated at the origin first.

nginx configuration

There was a separate cache rules file prepared for the server:

nginx/conf.d/expires.inc

It contained rules for HTML, feeds, media files and CSS/JS:

# HTML / generated data — do not cache aggressively
location ~* \.(?:manifest|appcache|html?|xml|json)$ {
  expires -1;
  add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}

# Feed
location ~* \.(?:rss|atom)$ {
  expires 1h;
  add_header Cache-Control "public" always;
}

# Media: images, icons, fonts, video
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|woff2?)$ {
  expires 30d;
  access_log off;
  add_header Cache-Control "public" always;
}

# CSS and JS without fingerprints — reasonable cache, but not one year
location ~* \.(?:css|js)$ {
  expires 1h;
  access_log off;
  add_header Cache-Control "public" always;
}

The file existed, but that does not automatically mean nginx was using it.

When the configuration is split across several files, it is worth confirming the active nginx configuration or simply checking the HTTP headers. The mere presence of a configuration file does not mean it is loaded by the active server {} block.

After checking default.conf, it turned out that expires.inc was not included in the active server block.

The configuration fragment looked like this:

server {
    listen       8080;
    server_name  localhost;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    location = /metrics {
        stub_status;
        access_log off;

        allow 127.0.0.1;
        allow 172.16.0.0/12;
        allow 172.17.0.0/16;
        allow 172.18.0.0/16;
        allow 172.19.0.0/16;
        allow 192.168.0.0/16;
        deny all;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

So the cache rules were prepared, but they were not loaded.

Fixing the nginx configuration

The first step was to clean up default.conf and add the include inside the server {} block:

server {
    listen 8080;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html index.htm;

    include /etc/nginx/conf.d/expires.inc;

    location / {
        try_files $uri $uri/ =404;
    }

    location = /metrics {
        stub_status;
        access_log off;

        allow 127.0.0.1;
        allow 172.16.0.0/12;
        allow 172.17.0.0/16;
        allow 172.18.0.0/16;
        allow 172.19.0.0/16;
        allow 192.168.0.0/16;
        deny all;
    }

    error_page 500 502 503 504 /50x.html;

    location = /50x.html {
        internal;
    }
}

At the same time, root was moved to the server level, and location / received an explicit:

try_files $uri $uri/ =404;

For a static Hugo website, this behavior makes sense. A non-existing URL should return 404, not pretend to be the homepage.

Second problem: missing extensions

After including expires.inc, it turned out that some assets still did not match the expected rules. The problem was in the extension list.

The original rule contained:

woff2?

This matches:

woff
woff2

but it does not match:

ttf

Additionally, the rule was missing, among others:

webp
avif
otf
eot

And the website was actually using files such as:

/fonts/webfonts/roboto.regular.ttf
/images/logo_logos.webp

The corrected rule looked like this:

# Media: images, icons, fonts, video
location ~* \.(?:jpg|jpeg|gif|png|webp|avif|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|woff|woff2|ttf|otf|eot)$ {
  try_files $uri =404;
  expires 30d;
  access_log off;
}

# CSS and JS without fingerprints — reasonable cache
location ~* \.(?:css|js)$ {
  try_files $uri =404;
  expires 7d;
  access_log off;
}

I left only expires, without the additional:

add_header Cache-Control "public" always;

Why?

Because:

expires 30d;

already generates the header:

Cache-Control: max-age=2592000

Adding a second Cache-Control could result in a response like this:

Cache-Control: max-age=2592000
Cache-Control: public

That usually works, but it is less readable. In this case, the simpler configuration was better.

Additionally, always in add_header can also mark error responses, for example 404, as public. For static assets, it is better to first make sure with try_files that the file really exists.

Result after fixing nginx

After rebuilding the container and restarting nginx, the local test started returning the expected headers:

curl -I http://192.168.1.254:1313/fonts/webfonts/roboto.regular.ttf

Response:

HTTP/1.1 200 OK
Server: nginx/1.30.3
Content-Type: font/ttf
Content-Length: 126072
Last-Modified: Fri, 26 Jun 2026 08:29:17 GMT
ETag: "6a3e385d-1ec78"
Expires: Sat, 01 Aug 2026 10:51:30 GMT
Cache-Control: max-age=2592000
Accept-Ranges: bytes

So nginx started correctly informing the browser that the file can be cached for 30 days.

Cloudflare: does it override headers?

The next test was done through the domain, so through Cloudflare:

curl -I https://logos.net.pl/fonts/webfonts/roboto.regular.ttf | grep -Ei 'cache-control|expires|cf-cache-status|age'

Response:

cache-control: public, max-age=2592000
expires: Sat, 01 Aug 2026 11:27:09 GMT
age: 65
cf-cache-status: HIT

This confirmed that Cloudflare was respecting the headers from nginx.

In the Cloudflare dashboard, Browser Cache TTL was set to 4 hours, but after fixing the origin headers, Cloudflare started returning values consistent with nginx.

Conclusion: before changing Cloudflare settings, it is worth checking what the origin really returns.

Trap with an old URL

During testing, another interesting case appeared. For the old address:

/css/bootstrap.min.css

Cloudflare still showed:

cache-control: public, max-age=14400
cf-cache-status: HIT

Only the local test showed the real problem:

curl -I http://192.168.1.254:1313/css/bootstrap.min.css

Response:

HTTP/1.1 404 Not Found
Server: nginx/1.30.3
Content-Type: text/html
Content-Length: 153
Cache-Control: public

That file simply did not exist at this path.

The current Bootstrap path was different:

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

And the Hugo template contained this entry:

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

So the problem was not related to the active CSS file, but to an old URL that was still visible in cache or in the testing history.

This is a good example of why, when debugging cache, it is worth checking the full HTTP status, not just selected headers through grep.

Final tests

After deployment, it is worth checking several asset types.

Fonts:

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

Images:

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

Video:

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

CSS:

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

HTML:

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

For static assets, we expect a longer cache lifetime. For HTML, rather a short cache or no-cache, because HTML is what points to the current versions of CSS and JS assets.

Conclusions

The most important conclusion from this diagnosis is simple:

Do not ask what should work. Check what the server actually returns.

In this case, the problem was not one big failure, but several small inconsistencies across different layers:

the cache rules file existed, but nginx was not loading it
some extensions did not match the expected location block
Cloudflare showed 4h, but after fixing the origin it respected nginx headers
an old Bootstrap URL returned 404, but still confused the tests

Lighthouse showed the symptom, but solving it required checking the real HTTP headers at each layer.

In practice, a good diagnostic toolkit looks like this:

curl -I       → check HTTP headers
nginx -T      → check the active nginx configuration
Cloudflare    → check whether the edge respects the origin
Lighthouse    → validate the final result

At this point, static files had proper cache headers. However, one important topic remained: style.css.

If a CSS file is always available under the same address, for example /css/style.css, a long cache lifetime can cause problems after deployment. The browser may keep using the old version of the file even though the server already has a new one.

The solution is fingerprinting, which means generating a file name based on its content. I will cover that in the next post.