089 —Operations
Apache vs Nginx headers: veldgids voor legacy hosting
De response headers van een verouderde site verraden welke webserver draait, wie de pagina cachet en wat er stilletjes tussen jou en de origin zit. Zo lees je ze.
Een klant stuurt je vrijdagmiddag een staging-URL door. Voordat je inlogt, voordat je om SSH vraagt, voordat je ook maar één regel PHP openslaat, draai je curl -I https://staging.example.com en kijk je naar wat er terugkomt. Zes regels headers. Goed gelezen vertellen ze je of Apache of Nginx antwoordt, wie de response cachet, wat hem comprimeert, en of er iets stilletjes tussen jou en de origin zit. Slordig gelezen sturen ze je de rest van de middag de verkeerde kant op.
Dit is een veldgids voor die headers, specifiek voor Apache en Nginx op het soort shared hosting waar je niet via SSH binnenkomt en het controlepaneel de versienummers verstopt. Het doel is niet om elke entry in de MDN reference te dekken, alleen de regels die bepalen hoe je een legacy site debugt.
De Server-regel en wat hij verbergt
De eerste header waar de meeste mensen naar kijken is ook degene waar het meest over wordt gelogen. Server: Apache kan Apache 2.4 zijn die direct met PHP-FPM praat. Het kan ook Apache zijn die voor LiteSpeed staat, of Nginx met een vhost die server_tokens onderweg herschrijft. Shared hosts redigeren dit voortdurend.
$ curl -sI https://example.com | head -n 6
HTTP/2 200
date: Tue, 10 Jun 2026 07:14:02 GMT
content-type: text/html; charset=UTF-8
server: Apache
x-powered-by: PHP/7.4.33
link: <https://example.com/wp-json/>; rel="https://api.w.org/"
Twee dingen om op te merken. server: Apache zonder versienummer betekent meestal dat de host ServerTokens Prod heeft ingesteld, een gewoonte beschreven in de Apache core docs, die het patch-niveau verbergt. x-powered-by: PHP/7.4.33 is nuttiger: PHP 7.4 is sinds november 2022 end of life, wat je meteen vertelt hoe het beveiligingsgesprek met de klant eruit gaat zien. De link header met wp-json erin bevestigt WordPress voordat je de homepage überhaupt opent.
LiteSpeed onderscheiden van Apache
LiteSpeed is wire-compatible met Apache en leest .htaccess op dezelfde manier, dus de enige eerlijke aanwijzing is ergens in de stack een x-litespeed-cache of x-powered-by: LiteSpeed header. Zie je geen van beide, maar werken rewrite-regels duidelijk wel, ga er dan vanuit dat het Apache is en kijk verder.
Cache-headers ontcijferd
Drie headers dragen het cache-contract: Cache-Control, Expires, en de validators ETag en Last-Modified. Op Apache stel je die in via mod_expires en mod_headers:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 30 days"
ExpiresByType text/css "access plus 7 days"
ExpiresByType text/html "access plus 0 seconds"
</IfModule>
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=2592000" "expr=%{REQUEST_URI} =~ m#\.(jpg|png|webp)$#"
</IfModule>
Op Nginx krimpt dezelfde bedoeling tot een paar regels:
location ~* \.(jpg|png|webp)$ {
expires 30d;
add_header Cache-Control "public, immutable" always;
}
In beide gevallen moet de response op een JPG Cache-Control: public, max-age=2592000 tonen met een Expires 30 dagen verderop. Zie je Cache-Control: no-store, max-age=0 op een JPG, dan overschrijft iets verderop in de keten de origin. Op een verouderde WordPress-installatie is de gebruikelijke verdachte een security plugin die Header always set Cache-Control "no-cache" op elke admin-route plakt en de directive op de front-end laat lekken. Cache-Control wint van Expires bij elke moderne client volgens RFC 9111, dus eerst achter de Expires-mismatch aangaan is een verloren uur.
Validators zijn de andere helft van het verhaal. ETag is op een fingerprint gebaseerd, Last-Modified op een timestamp. Op een load-balanced Apache-farm kunnen ETag-waarden voor hetzelfde bestand per node verschillen, omdat het standaard Apache-formaat de inode meeneemt. De oplossing is ofwel FileETag MTime Size in .htaccess, of, eerlijker, ETags helemaal laten vallen en op Last-Modified leunen.
Compressie op de lijn
Compressie verschijnt op twee regels: Content-Encoding op de response, en Vary: Accept-Encoding zodat elke cache ertussen de gzip- en brotli-kopieën uit elkaar houdt.
$ curl -sI -H 'Accept-Encoding: br, gzip' \
https://example.com/wp-content/themes/site/style.css \
| grep -iE 'encoding|vary'
content-encoding: br
vary: Accept-Encoding
Content-Encoding: br is brotli, ondersteund in Apache via mod_brotli sinds 2.4.26 en in Nginx via de third-party ngx_brotli module. gzip is de veilige standaard. Komt er een HTML-pagina van 50 KB terug zonder enige Content-Encoding, dan staat compressie bij de host uit en zal je Lighthouse-score dat weerspiegelen.
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css text/javascript \
application/javascript application/json \
image/svg+xml
</IfModule>
Eén valkuil op oudere 2.2-bakken: mod_deflate en mod_expires werken niet altijd goed samen. Zie je Vary: Accept-Encoding maar geen Content-Encoding op dezelfde response, dan is de body één keer gecomprimeerd, ergens upstream ongecomprimeerd gecached, en wordt nu zonder de gzip-pas geserveerd. De oplossing is Vary correct zetten in de cache-laag, niet vechten met mod_deflate.
De fingerprint van de reverse proxy
De meeste verouderde sites hebben inmiddels minstens één reverse proxy ervoor staan, ook al is het bureau dat de site bouwde dat allang vergeten. Cloudflare is de voor de hand liggende. Varnish is degene die je stilletjes bijt.
Cloudflare laat drie signalen achter:
server: cloudflare
cf-ray: 8b3f2a1e5c8f1234-AMS
cf-cache-status: HIT
cf-cache-status: HIT betekent dat de asset van de Cloudflare-edge in Amsterdam kwam (het -AMS-achtervoegsel op de ray-ID) en nooit de origin heeft geraakt. MISS betekent dat dat net wel gebeurde. DYNAMIC betekent dat Cloudflare naar de response heeft gekeken en besloten heeft hem niet te cachen, meestal omdat PHP een Cache-Control: private teruggaf of een session cookie zette.
Varnish kondigt zichzelf anders aan:
via: 1.1 varnish (Varnish/6.0)
x-varnish: 32145678 32144567
age: 412
Twee integers op X-Varnish betekent een cache hit, en je leest de ID's van de huidige request en de oorspronkelijk gecachete request. Age: 412 zegt dat het object 412 seconden in de cache zit. Doe je curl -I twee keer en stijgt Age mee met de seconden op de klok tussen de calls, dan zit je in de cache. Reset het naar een klein getal, dan is het object net ververst of liep er een PURGE. Deze ene header heeft meer "waarom toont de homepage nog de oude hero"-tickets opgelost dan welke console-sessie ook.
LiteSpeed voegt x-litespeed-cache: hit of miss toe. Nginx met de proxy_cache module zet meestal x-cache: HIT als de beheerder dat via add_header heeft doorgezet. Daar is geen conventie voor, dus zie je een custom x-cache-* header, grep dan de vhost om te zien wie hem heeft gezet.
Een cheatsheet bouwen die je opnieuw kunt draaien
Als je eenmaal weet wat elke regel betekent, is de volgende stap het lezen goedkoop maken. Een shell-functie van één regel doet het:
peek() {
curl -sI -H 'Accept-Encoding: br, gzip' "$1" \
| grep -iE '^(server|x-powered-by|cache-control|expires|age|vary|content-encoding|cf-cache-status|x-varnish|x-litespeed-cache):'
}
$ peek https://example.com/
server: cloudflare
cache-control: max-age=14400, public
age: 9821
vary: Accept-Encoding
content-encoding: br
cf-cache-status: HIT
Gooi dat in je ~/.bashrc. De volgende keer dat een klant meldt dat "de nieuwe CSS niet laadt", is het eerste commando dat je draait peek tegen de asset-URL. Negen van de tien keer is het antwoord ofwel age: 9821 (Varnish of Cloudflare houdt hem vast) ofwel cache-control: max-age=2592000 (de browser doet het, en de klant moet een hard-reload doen).
Toen we Pier bouwden liepen we precies tegen dit patroon aan, keer op keer: een klant die een stylesheet over FTP bewerkte, op opslaan drukte, en in een onveranderde pagina terechtkwam omdat twee cache-lagen tussen hem en het bestand zaten. Onze oplossing werd om de live response headers naast elk bestand in de editor te tonen, zodat de version history op schijf en de age op de lijn in één view samenvallen, met datzelfde paneel naast de MySQL editor voor wp_options autoload-rijen.
Het kleinste wat je vandaag kunt doen: pak de drie URL's die je klanten het vaakst verversen, draai curl -sI tegen elk, en plak de output in een notitie. De volgende keer dat er iets "niet bijwerkt", heb je een bekend-goede baseline om tegen te diffen.
— Vragen —
Kan ik de Server-header op een shared host vertrouwen?
Niet helemaal. Hosts strippen de versie met ServerTokens Prod, en reverse proxies overschrijven hem. Behandel het als een werkhypothese, niet als feit, en kruiscontroleer met x-powered-by en de fingerprints van de proxy.
Waarom wint Cache-Control van Expires?
Volgens RFC 9111 respecteren moderne clients Cache-Control als beide aanwezig zijn. Expires is alleen nog een fallback voor HTTP/1.0-caches die je in productie vrijwel nooit meer tegenkomt.
Wat is de snelste manier om Cloudflare van Varnish te onderscheiden?
cf-ray en cf-cache-status betekenen Cloudflare. via en X-Varnish betekenen Varnish. Verschijnen ze allebei, dan staat Cloudflare voor Varnish, wat werkt maar het invalidatiewerk verdubbelt.