Dlaczego Lighthouse pokazywał 4h cache? Debugowanie nginx i Cloudflare
Wstęp
Przy okazji dopracowywania strony statycznej zbudowanej w Hugo odpaliłem Lighthouse, żeby sprawdzić, czy nie ma jeszcze prostych rzeczy do poprawy.
Jednym z komunikatów było ostrzeżenie dotyczące zbyt krótkiego cache dla statycznych zasobów. Lighthouse pokazywał między innymi pliki CSS, fonty, obrazki oraz wideo, które według niego były cache’owane tylko przez około 4 godziny.
Na pierwszy rzut oka temat wyglądał prosto: ustawić dłuższy cache i koniec. W praktyce okazało się, że po drodze jest kilka warstw:
przeglądarka użytkownika
↓
Cloudflare
↓
Traefik
↓
nginx w kontenerze
↓
statyczna strona Hugo
I jak zwykle przy kilku warstwach — warto najpierw sprawdzić, co naprawdę się dzieje, zamiast zakładać, że „to pewnie Cloudflare”.
Objaw
Lighthouse zgłaszał problem z cache dla plików statycznych, między innymi:
/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
W raporcie pojawiał się czas około 4 godzin.
Ponieważ strona działa za Cloudflare, pierwsza myśl była oczywista: Cloudflare ma ustawione Browser Cache TTL na 4 godziny i nadpisuje nagłówki z originu.
Ale zanim zacząłem zmieniać ustawienia Cloudflare, sprawdziłem origin bezpośrednio.
Sprawdzenie nagłówków bez Cloudflare
Najpierw sprawdziłem odpowiedź bezpośrednio z nginx-a w kontenerze:
curl -I http://192.168.1.254:1313/fonts/webfonts/roboto.regular.ttf
Odpowiedź wyglądała mniej więcej tak:
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
Brakowało najważniejszych nagłówków:
Cache-Control: ...
Expires: ...
To od razu zawęziło problem. Skoro nginx nie zwracał nagłówków cache, to Cloudflare nie miał czego respektować. Problem trzeba było zacząć od originu.
Konfiguracja nginx
Dla serwera był przygotowany osobny plik z regułami cache:
nginx/conf.d/expires.inc
Zawierał między innymi reguły dla HTML, feedów, mediów oraz CSS/JS:
# HTML / dane generowane — nie cache'ować agresywnie
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: obrazy, ikony, fonty, wideo
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 i JS bez fingerprintów — rozsądny cache, ale nie rok
location ~* \.(?:css|js)$ {
expires 1h;
access_log off;
add_header Cache-Control "public" always;
}
Sam plik istniał, ale to jeszcze nie znaczy, że nginx go używa.
W przypadku konfiguracji dzielonej na kilka plików warto potwierdzić aktywną konfigurację nginx-a albo po prostu sprawdzić nagłówki HTTP. Sama obecność pliku konfiguracyjnego nie oznacza jeszcze, że jest on ładowany przez aktywny server {}.
Po sprawdzeniu default.conf okazało się, że plik expires.inc nie był dołączony w aktywnym bloku server.
Fragment konfiguracji wyglądał tak:
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;
}
}
Czyli reguły cache były przygotowane, ale nie były ładowane.
Poprawka konfiguracji nginx
Pierwszym krokiem było uporządkowanie default.conf i dodanie include wewnątrz bloku server {}:
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;
}
}
Przy okazji root został przeniesiony na poziom server, a w location / pojawiło się jawne:
try_files $uri $uri/ =404;
Dla statycznej strony Hugo jest to sensowne zachowanie. Nieistniejący adres powinien dawać 404, a nie udawać stronę główną.
Drugi problem: brakujące rozszerzenia
Po dołączeniu expires.inc okazało się, że część zasobów nadal nie wpada w odpowiednie reguły. Problem był w liście rozszerzeń.
Pierwotna reguła zawierała:
woff2?
To łapie:
woff
woff2
ale nie łapie:
ttf
Dodatkowo w regule brakowało między innymi:
webp
avif
otf
eot
A strona faktycznie używała plików:
/fonts/webfonts/roboto.regular.ttf
/images/logo_logos.webp
Poprawiona reguła wyglądała tak:
# Media: obrazy, ikony, fonty, wideo
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 i JS bez fingerprintów — rozsądny cache
location ~* \.(?:css|js)$ {
try_files $uri =404;
expires 7d;
access_log off;
}
Zostawiłem samo expires, bez dodatkowego:
add_header Cache-Control "public" always;
Dlaczego?
Bo:
expires 30d;
sam generuje nagłówek:
Cache-Control: max-age=2592000
Dodanie drugiego Cache-Control mogło kończyć się odpowiedzią w stylu:
Cache-Control: max-age=2592000
Cache-Control: public
To zwykle działa, ale jest mniej czytelne. W tym przypadku prostsza konfiguracja była lepsza.
Dodatkowo always przy add_header potrafi oznaczyć także odpowiedzi błędów, na przykład 404, jako public. Przy statycznych zasobach lepiej najpierw upewnić się przez try_files, że plik faktycznie istnieje.
Efekt po poprawce nginx
Po przebudowaniu kontenera i restarcie nginx-a test lokalny zaczął zwracać oczekiwane nagłówki:
curl -I http://192.168.1.254:1313/fonts/webfonts/roboto.regular.ttf
Odpowiedź:
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
Czyli nginx zaczął poprawnie informować przeglądarkę, że plik może być cache’owany przez 30 dni.
Cloudflare: czy nadpisuje nagłówki?
Następny test był już po domenie, czyli przez Cloudflare:
curl -I https://logos.net.pl/fonts/webfonts/roboto.regular.ttf | grep -Ei 'cache-control|expires|cf-cache-status|age'
Odpowiedź:
cache-control: public, max-age=2592000
expires: Sat, 01 Aug 2026 11:27:09 GMT
age: 65
cf-cache-status: HIT
To potwierdziło, że Cloudflare respektuje nagłówki z nginx-a.
W panelu Cloudflare opcja Browser Cache TTL była ustawiona na 4 godziny, ale po poprawce origin headers Cloudflare zaczął zwracać wartości zgodne z nginx-em.
Wniosek: zanim zmieni się ustawienia Cloudflare, warto sprawdzić, co naprawdę zwraca origin.
Pułapka ze starym URL-em
Podczas testów pojawił się jeszcze jeden ciekawy przypadek. Dla starego adresu:
/css/bootstrap.min.css
Cloudflare nadal pokazywał:
cache-control: public, max-age=14400
cf-cache-status: HIT
Dopiero test lokalny pokazał prawdziwy problem:
curl -I http://192.168.1.254:1313/css/bootstrap.min.css
Odpowiedź:
HTTP/1.1 404 Not Found
Server: nginx/1.30.3
Content-Type: text/html
Content-Length: 153
Cache-Control: public
Ten plik po prostu nie istniał pod tym adresem.
Aktualna ścieżka do Bootstrapa była inna:
/vendor/bootstrap/5.3.8/css/bootstrap.min.css
A w szablonie Hugo znajdował się wpis:
<link rel="stylesheet" href="{{ "/vendor/bootstrap/5.3.8/css/bootstrap.min.css" | relURL }}">
Czyli problem nie dotyczył aktywnego pliku CSS, tylko starego URL-a, który nadal był widoczny w cache albo w historii testów.
To dobry przykład, dlaczego przy debugowaniu cache warto zawsze sprawdzić pełny status HTTP, a nie tylko wybrane nagłówki przez grep.
Testy końcowe
Po wdrożeniu warto sprawdzić kilka typów zasobów.
Fonty:
curl -I https://logos.net.pl/fonts/webfonts/roboto.regular.ttf \
| grep -Ei 'cache-control|expires|cf-cache-status|age'
Obrazy:
curl -I https://logos.net.pl/images/logo_logos.webp \
| grep -Ei 'cache-control|expires|cf-cache-status|age'
Wideo:
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'
Dla statycznych zasobów oczekujemy dłuższego cache. Dla HTML-a raczej krótkiego cache albo no-cache, ponieważ to właśnie HTML wskazuje aktualne wersje zasobów CSS i JS.
Wnioski
Najważniejszy wniosek z tej diagnostyki jest prosty:
Nie pytaj, co powinno działać. Sprawdź, co naprawdę zwraca serwer.
W tym przypadku problem nie był jedną wielką awarią, tylko kilkoma małymi niespójnościami na różnych warstwach:
plik z regułami cache istniał, ale nie był ładowany przez nginx
część rozszerzeń nie wpadała w odpowiedni blok location
Cloudflare pokazywał 4h, ale po poprawce originu respektował nagłówki nginx
stary URL do Bootstrapa dawał 404, ale nadal mieszał w testach
Lighthouse pokazał objaw, ale rozwiązanie wymagało sprawdzenia realnych nagłówków HTTP na każdej warstwie.
W praktyce dobry zestaw diagnostyczny wygląda tak:
curl -I → sprawdzenie nagłówków HTTP
nginx -T → sprawdzenie aktywnej konfiguracji nginx-a
Cloudflare → sprawdzenie, czy edge respektuje origin
Lighthouse → walidacja efektu końcowego
Na tym etapie statyczne pliki dostały poprawne nagłówki cache. Pozostał jednak jeden ważny temat: style.css.
Jeżeli plik CSS jest dostępny zawsze pod tym samym adresem, na przykład /css/style.css, długi cache może powodować problemy po deployu. Przeglądarka może nadal trzymać starą wersję pliku, mimo że na serwerze jest już nowa.
Rozwiązaniem jest fingerprinting, czyli generowanie nazwy pliku zależnej od jego zawartości. Tym zajmę się w kolejnym wpisie.
