How to Enable Browser Cache in Nginx

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:

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

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

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

Best Practices

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.

Exit mobile version