Nginx Static Asset Caching Strategy and Cache-Control Header Tuning

devops-nginx

Nginx static asset caching strategy and Cache-Control header tuning is one of those configuration areas where a few well-placed directives make a measurable difference — both in perceived load time and in CDN efficiency. The underlying logic is straightforward: tell browsers and intermediary caches exactly how long each asset type is valid, and let them do the work of avoiding redundant requests.

The tricky part is that static asset types do not all deserve the same treatment. A content-hashed JavaScript bundle can safely live in cache for a year. Your index.html should never be cached without revalidation. Getting that distinction wrong is the most common cause of stale deploys reaching real users — and it is entirely preventable with a structured Nginx configuration.

Requirements

Nginx static asset caching strategy and illustration

Before writing a single directive, confirm the environment matches what this walkthrough assumes. Nginx static asset caching strategy works best when your build pipeline produces content-hashed filenames for JS, CSS, and font assets — filenames like app.4f3c9a.js rather than app.js. That naming convention is what makes aggressive max-age values safe.

You will need:

  • Nginx 1.18 or later on a Debian/Ubuntu-based server (the config path assumes /etc/nginx/sites-available/)
  • Your site’s document root already serving files — typically something like /var/www/yourdomain/public
  • The global MIME type map at /etc/nginx/mime.types included in your http block (it is included by default in most distributions)
  • A build process that hashes JS and CSS filenames on every production build
  • gzip or Brotli compression configured in the parent http block if you intend to use gzip_static on;

One important scoping note: expires and add_header Cache-Control control client-side caching. They instruct browsers and CDNs. The proxy_cache_path directive is a separate Nginx mechanism for caching upstream responses on the server itself — do not conflate the two when reading documentation.

For background on how Nginx processes configuration inheritance, the official ngx_http_headers_module documentation covers directive inheritance rules in detail, including the silent-drop behavior discussed below.

Implementation

The configuration below organises assets into four groups: HTML entry points, versioned JS and CSS, images and fonts, and JSON/XML data files. Each group gets its own location block with a matching Cache-Control policy. We place this in /etc/nginx/sites-available/yourdomain.conf.

Before applying it, note one critical Nginx behaviour: add_header directives in a child location block silently discard any headers set in a parent block or the server block. This means security headers like X-Content-Type-Options must be restated inside every location block that uses add_header, or they will disappear from responses for those paths. The configuration below handles this by declaring security headers at the server block level and then restating them where needed — a pattern worth enforcing consistently across all your Nginx configs.

# /etc/nginx/sites-available/yourdomain.conf

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

    # --- HTML entry points: always revalidate ---
    location ~* \.html$ {
        expires -1;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
    }

    # --- Versioned/hashed JS and CSS: cache aggressively ---
    location ~* \.(js|css)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Vary "Accept-Encoding";
        access_log off;
        gzip_static on;
    }

    # --- Images and fonts: long cache, no immutable flag ---
    location ~* \.(png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|otf)$ {
        expires 6M;
        add_header Cache-Control "public, max-age=15552000";
        add_header Vary "Accept-Encoding";
        access_log off;
    }

    # --- JSON/XML data files: short cache with revalidation ---
    location ~* \.(json|xml)$ {
        expires 1h;
        add_header Cache-Control "public, max-age=3600, must-revalidate";
    }

    # --- Default fallback for unmatched static files ---
    location / {
        try_files $uri $uri/ =404;
        expires 1d;
        add_header Cache-Control "public, max-age=86400";
    }

    # --- ETag and conditional request support ---
    etag on;
    if_modified_since exact;

    # --- Security headers (restate here; not inherited from http block) ---
    add_header X-Content-Type-Options "nosniff" always;
}

A few decisions in this config are worth making explicit:

The immutable flag on JS and CSS tells browsers not to revalidate these assets even when the user performs a forced refresh. This is only safe because the filenames are content-hashed — if the file changes, the filename changes, and the browser fetches a new URL. Images and fonts intentionally omit immutable because their filenames are often not hashed.

Setting expires 1y; is equivalent to writing Cache-Control: max-age=31536000. Nginx generates both the Expires header and the Cache-Control: max-age value from a single directive. When you also write an explicit add_header Cache-Control, the explicit header takes precedence — which is why we include the full value there rather than relying on the implicit one from expires.

The gzip_static on; directive in the JS/CSS block tells Nginx to serve a pre-compressed .gz file if one exists alongside the original. This pairs naturally with long cache headers: the compressed file is served once, cached by the browser, and never requested again until the hash changes. Your build pipeline should produce .gz versions alongside every hashed asset.

For more on Nginx caching patterns in production environments, the DevOps_DayS blog covers related infrastructure topics including reverse proxy configuration and CDN integration strategies.

Test the Setup

Validate the configuration file before any reload:

nginx -t

If the syntax check passes, reload without dropping connections:

systemctl reload nginx

Now verify the actual headers being sent. Use curl -I against each asset type to inspect the response headers directly:

curl -I https://yourdomain.com/assets/app.4f3c9a.js

The response should include:

Cache-Control: public, max-age=31536000, immutable
Vary: Accept-Encoding
Expires: <date one year from now>
ETag: "<hash>"

Check the HTML entry point separately to confirm it is not being cached:

curl -I https://yourdomain.com/index.html

Expected output:

Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: Thu, 01 Jan 1970 00:00:01 GMT

In browser DevTools, open the Network tab, disable the cache toggle, reload the page, and then re-enable the cache and reload again. Assets with a max-age greater than zero should show 200 (from disk cache) or 304 Not Modified on the second load, depending on whether the browser chooses to revalidate via ETag.

If you see unexpected 200 responses on repeat loads for long-cached assets, check whether a CDN or reverse proxy in front of Nginx is stripping or overriding the headers. Some CDN configurations require explicit cache rules at the CDN layer in addition to what Nginx sends.

One final check: confirm MIME types are resolving correctly for .woff2 and .svg files. Misconfigured or missing MIME entries in /etc/nginx/mime.types can cause browsers to refuse to cache certain file types regardless of what the Cache-Control header says.

  • Always run nginx -t before every reload — syntax errors in location blocks can take down the entire virtual host
  • Reload with systemctl reload nginx rather than restart to avoid dropping active connections
  • Never apply immutable to assets without content-hashed filenames — it prevents revalidation even when files change
  • Restate all add_header directives inside every child location block; Nginx does not inherit them from parent blocks
  • Test both a fresh load and a cached reload in DevTools to confirm the full cache lifecycle is working as intended

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank