Every time a visitor opens a second page on your site, their browser asks for the same logo, stylesheet and scripts again unless you tell it they are safe to reuse. Browser caching headers do that. With a few location rules in Nginx, static files load straight from the visitor's disk on repeat views, and your server handles fewer requests. This guide sets sensible cache lifetimes and keeps HTML fresh so updates still appear.
Before you start
- A Linux VPS with Nginx serving your site.
- The site's server block file, for example
/etc/nginx/sites-available/example.comor/etc/nginx/conf.d/example.com.conf. - Know whether your asset file names change when their content changes (for example
app.3f9a1c.js). Most build tools and WordPress caching plugins do this or add?ver=query strings.
How browser caching works
Two response headers control it:
Cache-Control: max-age=Ntells the browser it may reuse the file for N seconds without asking the server.Expiresgives an absolute date. It is older; when both are present, browsers followCache-Control.
Nginx's expires directive sets both for you. For example, expires 30d; sends Cache-Control: max-age=2592000 and a matching Expires date.
Once the time runs out, the browser checks with the server using ETag or Last-Modified. If nothing changed, Nginx answers 304 Not Modified with no body. Nginx sends these validators for static files by default.
Choose cache lifetimes
A safe starting point:
- Images, fonts, icons: 30 days to 1 year.
- CSS and JavaScript: 1 year if file names or query strings change on every release. Otherwise 7 days.
- HTML pages: do not cache, or revalidate every time.
- API responses and logged-in pages: no caching.
Warning: A long cache on a file whose name never changes means returning visitors keep the old version until it expires. Only use long lifetimes on fingerprinted assets, or accept a delay before changes appear.
Add the caching rules
Open the site's server block and add these location blocks inside the server { } that serves the site (the 443 block if you use HTTPS):
location ~* \.(?:jpg|jpeg|png|gif|webp|avif|ico|svg|mp4|webm)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
try_files $uri =404;
}
location ~* \.(?:woff2?|ttf|otf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location ~* \.(?:css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}Notes:
expiresalready setsCache-Control: max-age. The extraadd_header Cache-Controladds a second header line withpublicandimmutable, and browsers combine them.immutabletells the browser not to revalidate even when the user reloads. Use it only for fingerprinted files.access_log offkeeps your logs focused on page views.try_files $uri =404makes missing assets return a fast 404 instead of hitting your app.
If your CSS and JS names do not change between releases, use expires 7d; and drop immutable.
Keep HTML fresh
HTML should be checked every time so new content appears straight away. For a static site, add to the main location /:
location / {
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache";
}no-cache does not mean "never store". It means "store, but check with the server before using it". With ETags in place, unchanged pages still come back as a cheap 304.
For PHP sites, the application usually sets its own headers on HTML. Leave that to the app or your page cache plugin.
Note: add_header in a location replaces every add_header inherited from the server block. If you set security headers at server level, repeat them in each location that has its own add_header, or put them in a snippet and include it in each.
Apply the change
sudo nginx -t && sudo systemctl reload nginxCheck it works
Look at the headers on a static file:
curl -sI https://example.com/wp-content/themes/mytheme/style.css | grep -iE "cache-control|expires|etag"Expected output:
expires: Mon, 08 Nov 2027 10:00:00 GMT
cache-control: max-age=31536000
cache-control: public, immutable
etag: "6560a3b1-1f2a"Then check HTML:
curl -sI https://example.com/ | grep -i cache-controlIn the browser, open developer tools, go to Network, and reload the page once normally. Static files should show (disk cache) or (memory cache) in the size column on the second load.
Troubleshooting
- No caching headers on assets: another
locationmatches first, often a broader regex above yours. Regex locations are checked in the order they appear. Move the caching blocks higher. - Security headers disappeared: an
add_headerin the new location replaced the inherited ones. Repeat them in that location. - Visitors see old CSS after an update: the file name did not change and has a long cache. Add a version string to its URL or rename the file.
- Static files now 404 on a WordPress or Laravel site: the files are served by the app, not from disk. Remove
try_files $uri =404from that location or usetry_files $uri /index.php?$args;. - Headers look different in the browser: a CDN in front of the site sets its own. Check with
curlagainst the origin.
Next steps
- Make each download smaller too: Enable gzip compression in Nginx.
- Cache whole PHP pages on the server: Speed up PHP sites with Nginx FastCGI cache.
- How browser, server and CDN caches fit together: Cache layers explained.
