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:

  • Reduces server load. Every request Nginx doesn’t have to re-read from disk or re-transmit fully is CPU and I/O you get back.
  • Speeds up page loads. A browser that already has style.css cached doesn’t need to fetch it again — the page renders faster.
  • Cuts bandwidth costs. This matters a lot if you’re paying for egress traffic on a cloud provider.
  • Improves user experience, especially for repeat visitors and mobile users on slower connections.

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:

  • A working Nginx installation (I’ll reference paths from a standard Ubuntu/Debian install: /etc/nginx/, but the same directives apply anywhere).
  • Root or sudo access to the server.
  • A basic server block already serving your site.
  • Comfort using a terminal text editor (I use nano in these examples, but vim works the same).

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:

  • access_log off; — I disable logging for static assets on high-traffic sites because logging every image request bloats log files fast and rarely provides useful information. On lower-traffic sites, I leave it on for debugging purposes.
  • try_files $uri =404; — ensures Nginx returns a proper 404 instead of falling through to PHP or another handler if the file doesn’t exist.
  • I intentionally scoped the regex to common static extensions rather than caching everything blindly.

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:

  • Don’t accidentally cache sensitive files. If your regex is too broad (matching .pdf for example) and you’re serving user-uploaded documents that require authentication, caching them publicly with Cache-Control: public could expose private content to shared caches or proxies. Use private instead of public for anything behind auth.
  • Directory listing must stay off. Confirm autoindex off; (Nginx’s default) so users can’t browse your static assets directory.
  • Avoid caching error pages as if they were valid content. Make sure your try_files fallback returns proper status codes, so a 404 doesn’t get accidentally cached as a 200 somewhere upstream.
  • Set X-Content-Type-Options: nosniff for static assets to prevent MIME-type sniffing attacks:
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:

  • Enable sendfile, tcp_nopush, and tcp_nodelay in the http block — these let the kernel handle file transmission more efficiently.
sendfile on;
tcp_nopush on;
tcp_nodelay on;
  • Combine with Gzip or Brotli compression for text-based assets (CSS, JS, SVG). Compression and caching work together — compress once, cache the compressed result for a long time.
  • Use a CDN in front of Nginx for globally distributed traffic. Nginx’s caching headers are what the CDN reads to decide how long to hold your content at the edge, so getting this right locally pays off doubly.
  • Monitor cache hit ratios if you’re running a reverse proxy cache in addition to static file caching — add_header X-Cache-Status $upstream_cache_status; is invaluable for this (more relevant when Nginx is proxying to a backend rather than serving files directly).

Real-World Use Cases

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

  • A WordPress site where images and theme assets got expires 1y while HTML pages stayed uncached (since they change with every post). This cut server response time noticeably on repeat visits.
  • A single-page React app with hashed build filenames, letting me cache JS/CSS bundles for a full year with immutable, since any code change produces a new filename automatically.
  • A documentation site serving PDFs and diagrams, where I used a 3-day cache window because documents got revised periodically and I didn’t have a cache-busting pipeline in place.
  • An e-commerce catalog with thousands of product images, where aggressive image caching combined with a CDN reduced origin bandwidth by well over half.

Best Practices Summary

  • Match static file caching to how often the content actually changes — not everything deserves a year-long cache.
  • Use cache-busting filenames for anything you cache aggressively.
  • Keep access_log off for high-volume static assets, but leave it on if you need per-file analytics.
  • Always test with curl -I after changes, don’t just trust nginx -t.
  • Use private instead of public for anything behind authentication.
  • Pair caching with compression for the best performance gains.
  • Reload, don’t restart, Nginx after config changes to avoid dropping active connections (systemctl reload nginx).

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.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx as a Load Balancer

How to Set Up Nginx as a Load Balancer

Next Post
How to Configure Nginx with FastCGI Cache

How to Configure Nginx with FastCGI Cache

Related Posts