How to Cache Static Content in Nginx

How to Cache Static Content in Nginx

How to Cache Static Content in Nginx

If there’s one change I recommend to almost every client running a web server, it’s this: cache your static content properly. I’ve lost count of how many times I’ve logged into a “slow” server, checked the response headers, and found that images, CSS, and JavaScript files were being served fresh on every single request — no caching headers, no browser cache instructions, nothing. Once I fix that, page load times drop, bandwidth bills shrink, and the origin server stops sweating over files that never change.

In this guide, I’m going to walk through everything I do when I set up static content caching in Nginx — from the basic concept to a full production-ready configuration, testing, troubleshooting, and the security and performance details that actually matter.

Why Caching Static Content Matters

Static content — images, CSS, JavaScript, fonts, PDFs, videos — doesn’t change on every request the way a dynamically generated HTML page might. Yet I still see servers treating a logo.png file exactly like a PHP-generated dashboard: no cache headers, no expiration, full round trip every time.

Caching static content solves several problems at once:

There are two layers of caching I usually configure: browser caching (telling the client to store the file locally for a set time) and Nginx’s own internal caching (using open_file_cache for file descriptors, and optionally a proxy cache layer if content is served through a backend).

Prerequisites

Before diving in, I assume you have:

You can check your Nginx version with:

nginx -v

Caching directives like expires and add_header have been stable across Nginx versions for years, so this will work whether you’re on an older LTS build or the latest mainline release.

Understanding the Key Directives

Before I show the full config, let me explain the directives I actually use.

expires

This is the simplest and most important directive for browser caching. It sets both the Expires and Cache-Control: max-age headers automatically.

expires 30d;

This tells the browser “you can reuse this file for 30 days without asking me again.”

add_header Cache-Control

I use this when I want more granular control than expires gives me — for example, adding public, immutable, or no-transform.

add_header Cache-Control "public, immutable";

open_file_cache

This isn’t about browser caching at all — it tells Nginx itself to cache file descriptors, metadata, and existence information in memory, so it doesn’t have to hit the filesystem on every request.

open_file_cache max=10000 inactive=60s;
open_file_cache_valid 80s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

etag

Nginx generates ETags by default for static files, which lets browsers do conditional requests (If-None-Match) instead of re-downloading unchanged content. I rarely turn this off.

Step-by-Step Configuration

Here’s how I set this up on a typical server.

Step 1: Locate Your Server Block

Open the relevant config file. On Debian/Ubuntu this is usually under /etc/nginx/sites-available/:

sudo nano /etc/nginx/sites-available/example.com

Step 2: Add a Location Block for Static Assets

I like to match static file extensions with a regex location block, separate from the main location / block that handles dynamic requests.

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

    # Static asset caching
    location ~* \.(jpg|jpeg|png|gif|ico|webp|svg|css|js|woff|woff2|ttf|eot|otf|mp4|webm|pdf)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
        access_log off;
        try_files $uri =404;
    }

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

A few notes on choices here:

Step 3: Add Global File Descriptor Caching

Inside the http block in /etc/nginx/nginx.conf, I add:

http {
    open_file_cache max=10000 inactive=60s;
    open_file_cache_valid 80s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;

    gzip on;
    gzip_vary on;
    ...
}

Step 4: Fine-Tune Cache Duration by Asset Type

Not everything should be cached for the same length of time. I usually split it like this:

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

# CSS/JS: shorter cache window in case of frequent deploys
location ~* \.(css|js)$ {
    expires 7d;
    add_header Cache-Control "public";
}

# Documents that might update
location ~* \.(pdf|doc|docx)$ {
    expires 1d;
    add_header Cache-Control "public";
}

If you use cache-busting filenames (like app.a1b2c3.js generated by a build tool), you can safely bump images and JS/CSS both up to 1y with immutable, since a new deploy produces a new filename anyway.

Step 5: Test the Configuration

Always test before reloading:

sudo nginx -t

If it comes back clean:

sudo systemctl reload nginx

A Complete Example Configuration

Here’s a full server block I’d actually deploy on a typical static/dynamic hybrid site:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    root /var/www/example.com/public;
    index index.html index.php;

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

    # CSS and JS
    location ~* \.(css|js)$ {
        expires 7d;
        add_header Cache-Control "public";
        access_log off;
        try_files $uri =404;
    }

    # Video and PDF
    location ~* \.(mp4|webm|pdf)$ {
        expires 3d;
        add_header Cache-Control "public";
        try_files $uri =404;
    }

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

    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; }
}

Testing Your Cache Configuration

I never assume a config works just because Nginx reloaded without errors. I verify the actual headers with curl:

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

Expected output should include something like:

HTTP/2 200
Cache-Control: public, immutable
Expires: Thu, 15 Aug 2027 10:00:00 GMT
ETag: "5f3a2b1c-4e21"

If Cache-Control and Expires aren’t showing up, the location block isn’t matching — usually a regex or ordering issue (more on that in troubleshooting).

I also check it in a real browser: open DevTools → Network tab → reload the page → click a static asset → confirm the “Size” column shows “(disk cache)” or “(memory cache)” on the second load instead of a fresh download.

Troubleshooting Common Issues

Headers not appearing at all. This almost always means another location block is matching the request before your caching block. Nginx location matching isn’t purely top-to-bottom — exact matches (=) and prefix matches interact with regex matches (~*) in a specific order. If you have a location / block using try_files that’s catching requests before they reach your regex block, move your static block above it or double-check there isn’t a more specific match stealing the request.

Cache not updating after a deploy. This is the classic caching headache. If you set expires 1y on app.js and then change the file without renaming it, users with cached copies won’t see updates until the cache expires or they hard-refresh. The fix is cache-busting — append a version query string or hash to the filename during your build process (app.a1b2c3.js), not editing the cache headers reactively.

open_file_cache_errors causing weird 404 behavior. If you enable open_file_cache_errors on; and then remove a file, Nginx may keep serving a cached “not found” or cached metadata for a short window. This is expected — it’s meant for performance, not real-time file management. If you need instant consistency (like a CMS uploading files dynamically), keep inactive short (30–60s).

Gzip not compressing already-cached files. Gzip compression happens at request time, not from a cache — this isn’t actually a caching bug, but I see people conflate the two. Check separately with:

curl -I -H "Accept-Encoding: gzip" https://example.com/style.css

Look for Content-Encoding: gzip in the response.

Testing shows stale headers even after reload. Sometimes it’s not Nginx — it’s an intermediate CDN or proxy caching the old headers. Bypass it with curl --resolve pointed directly at the origin IP, or add a cache-busting query string to rule out CDN interference.

Security Considerations

Caching static content is generally low-risk, but there are a few things I always double-check:

add_header X-Content-Type-Options "nosniff";

Performance Tips

A few extra things I do on top of the base config, especially for higher-traffic sites:

sendfile on;
tcp_nopush on;
tcp_nodelay on;

Real-World Use Cases

I’ve applied this exact pattern in a few different contexts:

Best Practices Summary

Caching static content is one of those changes that takes maybe fifteen minutes to configure properly but pays off every single day the server is running. Once you’ve got the pattern down, it’s really just a matter of applying it consistently across every site you manage.

Cache Invalidation Strategies

The hardest part of caching isn’t setting the headers — it’s dealing with the day you need to change a cached file. I rely on three approaches, usually in combination.

Filename-based versioning. This is the cleanest option by far. Instead of style.css, the build process outputs style.a1b2c3d4.css, where the hash changes whenever the file’s contents change. Since the URL itself changes, there’s no invalidation to do — the old cached copy simply stops being referenced, and the browser fetches the new filename fresh. Every modern frontend build tool (Vite, Webpack, Parcel) supports this out of the box, and I strongly recommend it over manually managing cache expiry windows.

Query string versioning. For projects without a build step, appending ?v=2 to asset URLs (style.css?v=2) forces a fresh fetch, since browsers treat the full URL — including the query string — as the cache key. This is less elegant than filename hashing but works fine for smaller sites managed by hand.

Short cache windows with revalidation. For files I can’t easily version (user-uploaded content, for example), I use a shorter expires value combined with ETags, so the browser periodically checks in with a lightweight conditional request (If-None-Match) instead of blindly trusting a long cache window. Nginx handles the ETag comparison automatically — if the file hasn’t changed, it responds 304 Not Modified with no body at all, which is nearly as fast as a full cache hit.

Caching Behind a CDN

Most production setups I build these days sit behind a CDN — Cloudflare, Fastly, or a cloud provider’s own edge network. It’s worth understanding how your Nginx cache headers interact with that layer, because they’re not the same cache.

The CDN edge reads your Cache-Control and Expires headers just like a browser would, and stores its own copy at edge locations around the world. This means a single origin request from the CDN can serve thousands of end users without ever touching your Nginx server again — which is exactly why getting your headers right at the origin matters even more once a CDN is involved; a mistake here doesn’t just affect one visitor’s browser cache, it affects how long stale content might be served globally from every edge node.

I also add an explicit s-maxage directive when I want to control CDN caching separately from browser caching:

add_header Cache-Control "public, max-age=3600, s-maxage=86400";

Here, browsers cache for one hour, but the CDN holds onto it for a full day — useful when you want fast propagation of updates to end users while still getting the bandwidth savings of long CDN-level caching.

Frequently Asked Questions

Does caching affect SEO? Not negatively, and often positively — faster page loads are a ranking factor, and search engine crawlers respect cache headers the same way browsers do, which can reduce unnecessary crawl load on your server.

Should I cache HTML pages the same way as images? Generally no. HTML often changes more frequently than static assets, so I either leave it uncached, use a very short expires window, or rely on a separate reverse-proxy caching layer (like fastcgi_cache or proxy_cache) with proper invalidation logic, rather than blunt browser-side expires headers.

What happens if I forget to update the cache duration after changing my caching strategy? Old cached copies in visitors’ browsers will keep using the previous headers until they naturally expire — there’s no way to remotely clear another person’s browser cache. This is exactly why filename-based versioning is worth the initial setup effort; it sidesteps the entire problem.

Is there a downside to caching too aggressively? Yes — if you set expires 1y on a file without a cache-busting strategy and then need to change it, some visitors will keep seeing the old version for up to a year. Aggressive caching is a trade-off that only makes sense paired with proper versioning.

Exit mobile version