Websites & apps

Set browser caching headers (expires and Cache-Control) in Nginx

Tell browsers how long to keep images, CSS, JavaScript and fonts with expires and Cache-Control in Nginx, while making sure HTML stays fresh after every update.

BeginnerLinux server5 min read

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.com or /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=N tells the browser it may reuse the file for N seconds without asking the server.
  • Expires gives an absolute date. It is older; when both are present, browsers follow Cache-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):

Nginx
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:

  • expires already sets Cache-Control: max-age. The extra add_header Cache-Control adds a second header line with public and immutable, and browsers combine them.
  • immutable tells the browser not to revalidate even when the user reloads. Use it only for fingerprinted files.
  • access_log off keeps your logs focused on page views.
  • try_files $uri =404 makes 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 /:

Nginx
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

Bash
sudo nginx -t && sudo systemctl reload nginx

Check it works

Look at the headers on a static file:

Bash
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:

Bash
curl -sI https://example.com/ | grep -i cache-control

In 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 location matches 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_header in 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 =404 from that location or use try_files $uri /index.php?$args;.
  • Headers look different in the browser: a CDN in front of the site sets its own. Check with curl against 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.
Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.