089 —Operations
Apache vs Nginx headers: a field guide for legacy hosting
The response headers on a legacy site tell you whether Apache or Nginx is running, who is caching the page, and what is silently rewriting your responses. Here is how to read them.
A client hands you a staging URL on Friday afternoon. Before you log in, before you ask for SSH, before you read a single line of PHP, you run curl -I https://staging.example.com and look at what comes back. Six lines of headers. Done well, they tell you whether Apache or Nginx is answering, who is caching the response, what is compressing it, and whether something is silently sitting between you and the origin. Read carelessly, they will mislead you for the rest of the afternoon.
This is a field guide to those headers, specifically for Apache and Nginx on the kind of shared hosting where you cannot SSH in and the control panel hides the version numbers. The aim is not to cover every entry in the MDN reference, only the ones that change how you debug a legacy site.
The Server line and what it hides
The first header most people look at is also the most lied about. Server: Apache can mean Apache 2.4 talking directly to PHP-FPM. It can also mean Apache fronting LiteSpeed, or Nginx with a vhost that rewrites server_tokens on the way out. Shared hosts redact this constantly.
$ 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/"
Two things to notice. server: Apache with no version number usually means the host has ServerTokens Prod set, a habit documented in the Apache core docs that hides the patch level. x-powered-by: PHP/7.4.33 is more useful: PHP 7.4 went end of life in November 2022, which tells you immediately what the security conversation with the client looks like. The link header with wp-json in it confirms WordPress before you ever load the homepage.
Telling LiteSpeed apart from Apache
LiteSpeed is wire-compatible with Apache and reads .htaccess the same way, so the only honest tell is an x-litespeed-cache or x-powered-by: LiteSpeed header somewhere in the stack. If you see neither but rewrite rules clearly work, assume Apache and move on.
Cache headers, decoded
Three headers carry the cache contract: Cache-Control, Expires, and the validators ETag and Last-Modified. On Apache, you set them through mod_expires and 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>
On Nginx, the same intent compresses into a few lines:
location ~* \.(jpg|png|webp)$ {
expires 30d;
add_header Cache-Control "public, immutable" always;
}
In both cases the response on a JPG should read Cache-Control: public, max-age=2592000 with an Expires 30 days out. If you see Cache-Control: no-store, max-age=0 on a JPG, something downstream is overriding the origin. The usual suspect on a legacy WordPress install is a security plugin that bolts Header always set Cache-Control "no-cache" onto every admin route and leaks the directive onto the front end. Cache-Control beats Expires in any modern client per RFC 9111, so chasing the Expires mismatch first is a waste of an hour.
Validators are the other half of the picture. ETag is fingerprint based, Last-Modified is timestamp based. On a load-balanced Apache farm, ETag values can differ between nodes for the same file because the default Apache format includes the inode. The fix is either FileETag MTime Size in .htaccess or, more honestly, dropping ETags altogether and leaning on Last-Modified.
Compression on the wire
Compression shows up on two lines: Content-Encoding on the response, and Vary: Accept-Encoding so any cache in between keeps the gzip and brotli copies separate.
$ 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, supported in Apache via mod_brotli since 2.4.26 and in Nginx via the third-party ngx_brotli module. gzip is the safe default. If a 50 KB HTML page comes back with no Content-Encoding at all, the host has compression off and your Lighthouse score will reflect it.
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css text/javascript \
application/javascript application/json \
image/svg+xml
</IfModule>
One gotcha on older 2.2 boxes: mod_deflate and mod_expires do not always cooperate. If you see Vary: Accept-Encoding but no Content-Encoding on the same response, the body was compressed once, cached uncompressed somewhere upstream, and is now being served without the gzip pass. The fix is to set Vary correctly in the cache layer, not to fight mod_deflate.
The reverse proxy fingerprint
Most legacy sites have at least one reverse proxy in front of them now, even if the agency that built the site has forgotten. Cloudflare is the obvious one. Varnish is the one that quietly bites.
Cloudflare leaves three signals:
server: cloudflare
cf-ray: 8b3f2a1e5c8f1234-AMS
cf-cache-status: HIT
cf-cache-status: HIT means the asset came from the Cloudflare edge in Amsterdam (the -AMS suffix on the ray ID) and never touched the origin. MISS means it just did. DYNAMIC means Cloudflare looked at the response and decided not to cache, usually because PHP returned a Cache-Control: private or set a session cookie.
Varnish announces itself differently:
via: 1.1 varnish (Varnish/6.0)
x-varnish: 32145678 32144567
age: 412
Two integers on X-Varnish means a cache hit, and you are reading the IDs of the current request and the original cached request. Age: 412 says the object has been in cache for 412 seconds. If you curl -I twice and Age climbs by the wall-clock seconds between the calls, you are hitting cache. If it resets to a small number, the object was just refreshed or a PURGE ran. This single header has resolved more "why is the homepage still showing the old hero" tickets than any console session.
LiteSpeed adds x-litespeed-cache: hit or miss. Nginx with the proxy_cache module typically adds x-cache: HIT if the operator wired it through add_header. There is no convention there, so when you see a custom x-cache-* header, grep the vhost for whoever set it.
Building a cheatsheet you can re-run
Once you know what each line means, the next step is to make reading them cheap. A one-line shell function does it:
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
Drop that into your ~/.bashrc. The next time a client reports "the new CSS isn't loading," the first command you run is peek against the asset URL. Nine times out of ten the answer is one of age: 9821 (Varnish or Cloudflare is holding it) or cache-control: max-age=2592000 (the browser is, and the client needs to hard-reload).
When we built Pier we ran into this exact pattern over and over: a client editing a stylesheet over FTP, hitting save, and refreshing into an unchanged page because two layers of cache sat between them and the file. The way we ended up handling it was to surface the live response headers next to every file in the editor, so the version history on disk and the age on the wire line up in one view, with the same panel sitting alongside the MySQL editor for wp_options autoload rows.
The smallest thing to do today: pick the three URLs your clients refresh most, run curl -sI against each, and paste the output into a note. The next time something "isn't updating," you have a known-good baseline to diff against.
— Questions —
Can I trust the Server header on a shared host?
Not entirely. Hosts strip the version with ServerTokens Prod, and reverse proxies overwrite it. Treat it as a starting hypothesis, not a fact, and cross-check against x-powered-by and the proxy fingerprints.
Why does Cache-Control beat Expires?
Per RFC 9111, modern clients honour Cache-Control when both are present. Expires is only a fallback for HTTP/1.0 caches you almost never see in production any more.
What is the fastest way to tell Cloudflare from Varnish?
cf-ray and cf-cache-status mean Cloudflare. via and X-Varnish mean Varnish. If both appear, Cloudflare is fronting Varnish, which works but doubles the invalidation work.
I see Vary: Accept-Encoding but no Content-Encoding. What happened?
A cache between you and the origin stored an uncompressed copy. Either fix the upstream Vary handling or set Cache-Control: no-transform on the origin to stop the rewrite.
Does any of this change on LiteSpeed?
Not much. LiteSpeed honours .htaccess, mod_expires and mod_headers semantics, and adds its own x-litespeed-cache header. Treat it as Apache for config purposes and watch the extra header for cache state.