Cache-Control Headers for Static Sites in Nginx
A self-hosted static site lives or dies on its caching headers. Get them right and returning visitors load pages from their own cache, with the server answering a handful of tiny 304s. Get them wrong in one direction and readers see stale pages after a deploy; get them wrong in the other and every visit downloads every asset again. Nginx makes the right policy easy to express and one particular mistake easy to make: its add_header inheritance rule silently drops headers in any location block that sets its own.
This guide sets caching headers for an Astro documentation site served directly by Nginx 1.26 on a single VM, measures the effect with curl and WebPageTest, and fixes the inheritance trap that had been removing security headers from every asset. It belongs to CDN Caching Rules for SSGs; the server setup itself is in Serving a Static Site with Nginx.
Prerequisites
- A build whose assets have content hashes in their filenames (
/_astro/index.4f8c1b.css) — the precondition for caching them forever, covered in Cache Busting with Content-Hashed Filenames. - Nginx serving the build output from a root directory.
curlfor checking headers.
The Policy
Step 1: The Configuration
# /etc/nginx/snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
server {
listen 443 ssl http2;
server_name docs.example.com;
root /srv/docs/current;
include snippets/security-headers.conf;
# Hashed build assets: cache for a year, never revalidate
location /_astro/ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# Unhashed static files
location ~* \.(?:ico|png|svg|webmanifest|txt|xml)$ {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=86400" always;
try_files $uri =404;
}
# HTML: always revalidate
location / {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=0, must-revalidate" always;
try_files $uri $uri/index.html =404;
}
error_page 404 /404.html;
location = /404.html {
include snippets/security-headers.conf;
add_header Cache-Control "public, max-age=60" always;
internal;
}
}
The security headers are included in every location. That is the fix for the trap in the next step.
Step 2: The add_header Inheritance Trap
The previous configuration had the security headers once, in the server block, and a location /_astro/ with its own add_header Cache-Control. Checking an asset:
curl -sI https://docs.example.com/_astro/index.4f8c1b.css | grep -iE 'cache-control|strict-transport|x-content'
# cache-control: public, max-age=31536000, immutable
No HSTS, no nosniff. Nginx's rule: a block inherits add_header directives from its parent only if it defines none of its own. Defining Cache-Control in the location dropped every security header for all CSS, JavaScript and images. An external scan had flagged "missing X-Content-Type-Options on script responses" for months without anyone connecting it to caching. The snippet-per-location pattern above fixes it; the headers-more module's more_set_headers, which merges instead of replacing, is an alternative where the module is available.
Step 3: Check Every Kind of Response
for u in / /guides/deploy/ /_astro/index.4f8c1b.css /favicon.ico /missing-page/; do
printf '%-30s ' "$u"; curl -sI "https://docs.example.com$u" | grep -iE '^(HTTP|cache-control)' | tr '\r\n' ' '; echo
done
The always parameter matters for the 404: without it, Nginx adds headers only to 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 responses, so the 404 page went out with no Cache-Control and no security headers.
Step 4: Revalidation With ETags
HTML with max-age=0, must-revalidate is only cheap if revalidation is cheap. Nginx sends ETag (based on modification time and size) and Last-Modified for files on disk, and answers conditional requests with 304. The deploy script used rsync without --times, so every deploy gave every file a new modification time and every ETag changed — even for the 95% of HTML that had not changed. Adding -t (preserve times) to rsync, and building with a fixed SOURCE_DATE_EPOCH so unchanged pages are byte-identical, made most revalidations after a deploy return 304.
rsync -a --checksum --delete dist/ deploy@docs:/srv/docs/releases/$REL/
--checksum copies only files whose content changed, and -a preserves modification times on the rest.
Step 5: Compression and Vary
If Nginx compresses responses (gzip on; or serving precompressed files with gzip_static on;), it must send Vary: Accept-Encoding so caches between the server and the browser do not serve a compressed body to a client that cannot decode it. gzip_vary on; does that. Brotli and Gzip Precompression Served by Nginx covers precompression.
When a CDN Sits in Front
Many self-hosted sites later add a CDN in front of Nginx. The same headers work, with one addition: the CDN should be allowed to cache HTML longer than browsers do, because the CDN can be purged on deploy and browsers cannot. Adding s-maxage to the HTML location expresses that without changing browser behaviour:
add_header Cache-Control "public, max-age=0, s-maxage=86400, stale-while-revalidate=600, must-revalidate" always;
Browsers ignore s-maxage and keep revalidating every visit; the CDN keeps HTML for a day and serves it stale for up to ten minutes while refetching in the background. The deploy script then purges changed paths at the CDN, as described in Purging the CDN Cache After a Static Deploy. With that in place, Nginx served about 3% of requests on this site, almost all of them revalidations and post-deploy fetches.
Two Nginx settings needed checking once a CDN was involved. etag on stays useful, because the CDN revalidates against the origin with If-None-Match and receives 304s just as browsers did. And real_ip_header with the CDN's published address ranges in set_real_ip_from makes access logs show readers' addresses rather than the CDN's, which the team needed for the 404 report.
Testing the Headers in CI
Headers regress when someone adds a location block for a new feature. A short test runs against a staging server after every Nginx config change and fails if any kind of response lacks its expected Cache-Control or any security header:
check() { curl -sI "$STAGING$1" | grep -qi "^$2" || { echo "missing $2 on $1"; exit 1; }; }
for p in / /guides/deploy/ /_astro/index.css /favicon.ico /does-not-exist/; do
check "$p" "cache-control"; check "$p" "x-content-type-options"; check "$p" "strict-transport-security"
done
The asset path uses a stable alias that the deploy keeps pointing at the current CSS bundle.
Measured Impact
Repeat-visit performance on a mid-range phone, WebPageTest, first view followed by a repeat view of a different page, before and after:
| Measure (repeat view) | Before | After | Tool |
|---|---|---|---|
| Requests to origin | 24 | 3 (HTML 304, 2 new assets) | WebPageTest |
| Bytes transferred | 410 KB | 18 KB | WebPageTest |
| LCP | 1.9 s | 0.9 s | WebPageTest |
| Assets missing security headers | All CSS/JS/images | None | curl + scan |
| HTML 304 rate after a deploy | 4% | 93% | Nginx access log |
Before the change, assets had been sent with expires 7d from an old config, so repeat visits within a week mostly hit the cache; the measured "before" was a visit after eight days, which is typical for a docs site's returning readers.
Pitfalls & Rollback
add_headerin a location without the shared headers. Include them in every location.- Forgetting
always. Error responses then go out without headers. - Long
max-ageon HTML. Readers keep stale pages with no way to purge browsers. - Deploys that reset modification times. ETags change and revalidation stops saving anything.
- Rollback: the change is one server block and one snippet;
nginx -t && systemctl reload nginxapplies or reverts it in seconds.
Conclusion
In Nginx, a static site's caching comes down to three location blocks — hashed assets for a year with immutable, unhashed files for a day, HTML revalidated every time — plus a short-lived 404. Including shared headers in every location avoids the inheritance trap that had stripped security headers from every asset, and preserving file times on deploy made 93% of post-deploy HTML revalidations cheap 304s.
FAQ
Why do my Nginx add_header directives disappear in some locations?
add_header is inherited from the parent block only if the current block has no add_header of its own. As soon as a location defines one header, every header from the server block is dropped for that location. Repeat shared headers in each location, put them in an included snippet, or use the headers-more module, which does not have this behaviour.
What Cache-Control should HTML get on a static site?
Short or zero freshness with revalidation, such as public, max-age=0, must-revalidate, so browsers check for a new version on each visit and get a quick 304 when nothing changed. If a CDN sits in front, give it a longer s-maxage and purge on deploy. Never give HTML a long max-age without a way to purge it.
Should I use expires or add_header Cache-Control in Nginx?
Either. The expires directive sets both Expires and a Cache-Control max-age, which is convenient but does not add immutable or public. add_header Cache-Control gives full control over the directives. Using add_header alone, and expires off, avoids two directives fighting over the same header.
Does Nginx send ETag and Last-Modified for static files?
Yes, by default for files served from disk. Browsers use them to revalidate and receive 304 Not Modified responses. Deploys that rewrite every file with a new modification time change Last-Modified even when content is identical, so content-based ETags or preserving timestamps in the deploy tool reduce unnecessary downloads.
Related
- Parent: CDN Caching Rules for SSGs — caching policy across hosts.
- Serving a Static Site with Nginx — the full server configuration.
- Cache Busting with Content-Hashed Filenames — what makes
immutablesafe. - Cache-Control Headers on Cloudflare Pages — the same policy on a managed host.
- Nginx vs Caddy for Serving Static Sites — how Caddy handles the same headers.