How to Enable Browser Cache in Nginx

How to Enable Browser Cache in Nginx

One of the cheapest performance wins you can get on any website is telling browsers to cache your static assets properly. It costs nothing in infrastructure, requires no code changes, and can meaningfully cut down repeat page load times and bandwidth usage. Yet I still regularly audit sites where images, CSS, and JavaScript files are served with no caching headers at all, forcing every single visit to re-download everything from scratch.

In this guide, I’ll walk through exactly how browser caching works from Nginx’s perspective, how to configure it properly for different asset types, how to avoid the classic “users stuck on an old cached version” problem, and how to verify it’s actually working.

How Browser Caching Actually Works

When a browser requests a file, the server can include HTTP response headers that tell the browser how long it’s allowed to reuse that file without asking the server again. The two headers that matter most are:

  • Cache-Control — the modern, flexible header. Values like max-age=31536000 tell the browser exactly how many seconds it can treat the response as fresh.
  • Expires — the older HTTP/1.0 header, which specifies an absolute date after which the response is considered stale. Most modern setups keep this for backward compatibility, but Cache-Control takes precedence when both are present.

There’s also validation-based caching, using ETag and Last-Modified headers. Even after a cached resource expires, the browser can send a conditional request (If-None-Match or If-Modified-Since), and if the file hasn’t changed, the server responds with 304 Not Modified instead of resending the whole file. This saves bandwidth even for resources you don’t want to cache for long periods.

Understanding the difference between these two mechanisms is key to building a caching strategy that’s both fast and safe.

Requirements

  • Nginx installed and running.
  • Access to edit your site’s configuration under /etc/nginx/sites-available/ or /etc/nginx/conf.d/.
  • The ngx_http_headers_module, which is compiled into Nginx by default in essentially every distribution, so no extra installation is typically needed.

The expires Directive

Nginx’s simplest tool for browser caching is the expires directive. It automatically sets both Expires and Cache-Control: max-age headers based on a single value you provide.

location ~* \.(jpg|jpeg|png|gif|ico|webp|svg)$ {
    expires 30d;
}

This tells browsers they can cache matched images for 30 days without re-checking with the server. You can use various time units: 30d for 30 days, 1h for one hour, 1y for one year, or -1 (which sets Cache-Control: no-cache, effectively disabling caching for that block).

Setting Up Caching by Asset Type

Different file types deserve different caching strategies. Images, fonts, and versioned JS/CSS bundles rarely change once published, so they can be cached aggressively. HTML documents, on the other hand, should generally not be cached for long, since you want visitors to see fresh content.

server {
    listen 80;
    server_name example.com;
    root /var/www/example.com;

    # Long-lived cache for static assets that rarely change
    location ~* \.(jpg|jpeg|png|gif|ico|webp|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Medium cache for CSS/JS (adjust if you use cache-busting filenames)
    location ~* \.(css|js)$ {
        expires 7d;
        add_header Cache-Control "public";
    }

    # No caching for HTML so users always get the latest content
    location ~* \.html$ {
        expires -1;
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

I included access_log off; for static assets because logging every single image and font request adds unnecessary disk I/O on high-traffic sites — you rarely need per-request logs for static files that rarely change.

Understanding immutable

The immutable directive value tells the browser “this exact file will never change at this URL, don’t even bother checking back.” It’s extremely powerful when combined with cache-busting filenames (like app.a1b2c3.js), because it eliminates conditional revalidation requests entirely for the life of the cache period. Don’t use immutable on files that might change while keeping the same filename, or you’ll create a situation where users are stuck seeing stale content until the cache naturally expires.

Cache-Busting: Solving the “Old Version Stuck in Cache” Problem

Aggressive caching is great for performance but dangerous if you deploy a new version of a CSS or JS file with the same filename — users with a cached copy won’t see your changes until their cache expires naturally, which could be a year away if you set max-age=31536000.

The standard solution is cache-busting: include a hash or version number in the filename itself, so a new version has a new URL, forcing a fresh download.

style.css        →  style.a3f9c2.css
app.js           →  app.7e21bd.js

Most modern build tools (Webpack, Vite, Parcel, Django’s ManifestStaticFilesStorage, Rails asset pipeline) do this automatically. If you’re serving static files manually without a build tool, you can implement a simple versioning query string as a lighter-weight alternative:

<link rel="stylesheet" href="/style.css?v=3">

Query-string versioning works with most browsers and CDNs, but true filename hashing is more reliable across all caching layers, including some corporate proxies that strip query strings from cache keys.

Using map for Cleaner Cache Control Logic

If you have many different asset types with different rules, a map block keeps things organized:

http {
    map $sent_http_content_type $expires_time {
        default                    off;
        text/html                  -1;
        text/css                   7d;
        application/javascript     7d;
        ~image/                    30d;
        ~font/                     1y;
    }

    server {
        listen 80;
        server_name example.com;
        root /var/www/example.com;

        expires $expires_time;

        location / {
            try_files $uri $uri/ =404;
        }
    }
}

This approach caches based on the actual Content-Type Nginx determines for the response, rather than relying purely on file extension matching, which can be more robust when file types are ambiguous.

Complete Example Configuration

Here’s a full, production-ready example combining explicit rules by extension, gzip for text assets, and sensible defaults:

server {
    listen 80;
    server_name example.com www.example.com;
    root /var/www/example.com;
    index index.html;

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    location ~* \.(jpg|jpeg|png|gif|ico|webp|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        try_files $uri =404;
    }

    location ~* \.(woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        add_header Access-Control-Allow-Origin "*";
        access_log off;
        try_files $uri =404;
    }

    location ~* \.(css|js)$ {
        expires 30d;
        add_header Cache-Control "public";
        try_files $uri =404;
    }

    location ~* \.html$ {
        add_header Cache-Control "no-store, no-cache, must-revalidate";
        expires -1;
        try_files $uri =404;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Note the Access-Control-Allow-Origin header on fonts — this is necessary if you’re serving fonts from a different subdomain or CDN than the page referencing them, since browsers enforce CORS for cross-origin font loading.

Testing Your Configuration

After reloading Nginx:

sudo nginx -t
sudo systemctl reload nginx

Check the actual response headers with curl:

curl -I https://example.com/assets/logo.png

Look for Cache-Control and Expires in the output. You should see something like:

Cache-Control: public, immutable
Expires: Thu, 15 Aug 2027 12:00:00 GMT

You can also verify behavior directly in the browser: open Chrome or Firefox DevTools, go to the Network tab, reload the page, and check the “Size” column. Cached-from-disk requests typically show (disk cache) or (memory cache) instead of an actual transfer size, confirming the browser didn’t re-download the file.

For validation-based caching (ETags), check for a 304 Not Modified response on a repeat request:

curl -I -H 'If-None-Match: "<etag-value-from-first-request>"' https://example.com/assets/logo.png

Troubleshooting Common Issues

Headers aren’t showing up at all. Make sure your location regex is actually matching the requested files — a common mistake is a typo in the file extension list, or a more specific location block earlier in the file intercepting the request before it reaches your caching rules.

Users report seeing outdated content after a deploy. This is almost always a cache-busting problem — you updated a file’s contents but kept the same filename under a long max-age. Implement filename hashing or, at minimum, shorten the cache duration for actively changing assets.

Caching headers work locally but not through a CDN. Some CDNs strip or override origin cache headers by default, or apply their own rules. Check your CDN’s cache configuration separately — Nginx’s headers are a starting point, but the CDN sits between your server and the browser and can override them.

Cache-Control and Expires show conflicting values. If you’re combining add_header Cache-Control with the expires directive, make sure you’re not accidentally setting contradictory values (like expires -1; alongside Cache-Control: public, max-age=31536000 from a separate add_header). Keep your logic in one place per location block.

Security Considerations

Be careful about caching pages that include user-specific or sensitive data. Never apply aggressive public caching to authenticated pages, account dashboards, or any endpoint returning personalized or sensitive information — a shared cache (like a corporate proxy) could serve one user’s private data to another. Use Cache-Control: private, no-store for anything sensitive:

location /account {
    add_header Cache-Control "private, no-store";
}

Also avoid caching API responses that include tokens, session identifiers, or PII unless you’re deliberately building a caching layer with proper per-user cache key separation.

Performance Tips

  • Combine caching with gzip or Brotli compression for text-based assets (CSS, JS, SVG, JSON) — smaller payloads plus fewer repeat downloads compounds nicely.
  • Use access_log off; for high-volume static asset locations to reduce disk I/O.
  • Pair long browser cache lifetimes with a CDN in front of Nginx for globally distributed caching, reducing origin load even for first-time visitors in different regions.
  • Set open_file_cache to reduce filesystem metadata lookups for frequently requested static files:
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 120s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

Real-World Use Cases

  • Marketing and content sites: aggressively cache images, fonts, and versioned CSS/JS to make repeat visits nearly instant.
  • Single-page applications: cache the hashed JS/CSS bundles for a year, while keeping index.html uncached so users always get the latest bundle references.
  • Documentation sites: cache diagrams, screenshots, and downloadable PDFs long-term, since they change infrequently.
  • E-commerce product images: heavy caching reduces bandwidth costs significantly given how many images a typical storefront serves per session.
  • API-serving backends: selectively cache public, non-personalized GET endpoints (like a public product catalog) while explicitly preventing caching on anything user-specific.

Best Practices

  • Cache aggressively for content that’s versioned or rarely changes; cache minimally or not at all for HTML and personalized content.
  • Adopt cache-busting (filename hashing) as a standard part of your build/deploy process rather than relying on short cache lifetimes as a workaround.
  • Use immutable only when you’re certain the URL will never change contents.
  • Separate caching rules by content type using map if your rules start getting complex.
  • Regularly audit response headers with curl -I after deploys to make sure caching behavior matches your intent.
  • Document your caching strategy somewhere your team can reference — it’s easy to forget the reasoning behind specific max-age values months later.

Getting browser caching right is one of those unglamorous tasks that quietly makes your site feel faster for essentially every returning visitor. It’s worth the twenty minutes it takes to set up properly.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx with Docker

How to Set Up Nginx with Docker

Next Post
How to Implement IP-based Access Control in Nginx

How to Implement IP-based Access Control in Nginx

Related Posts