Browser cache headers decide whether visitors download the same CSS, JavaScript, font, and image files on every page view or reuse files they already have. To configure browser caching for static assets in NGINX safely, separate versioned build output from files that keep the same URL, then verify the response headers that browsers and CDNs actually receive.
This workflow uses Cache-Control as the primary browser-cache signal, keeps HTML and service-worker entry points revalidated, and reserves immutable for URLs that change when the file content changes. Browser caching is separate from NGINX proxy cache, NGINX open file cache, the NGINX sendfile directive, and NGINX gzip compression; those features solve different performance problems.
Plan Browser Cache Headers for NGINX Static Assets
Browsers decide whether a stored response is still fresh from response headers. Modern clients prefer Cache-Control; the older Expires header remains useful as a fallback for clients or intermediaries that still consult it. RFC 9111 defines the current HTTP caching model, including how max-age takes precedence over Expires when both are present.
| Header or Directive | Use | Practical Rule |
|---|---|---|
Cache-Control: max-age=N | Sets how many seconds the response remains fresh. | Use explicit values so browsers do not fall back to heuristic caching. |
immutable | Tells clients that a fresh response will not change at the same URL. | Use only for content-hashed or versioned asset URLs, not ordinary files such as /logo.svg. |
no-cache | Allows storage but requires revalidation before reuse. | Use for HTML entry points, asset manifests, and service-worker scripts that must discover new builds quickly. |
no-store | Asks caches not to store the response. | Use for sensitive or personalized responses, not normal public static files. |
add_header | Adds a custom response header in NGINX. | Use it when you need a full Cache-Control value such as public, max-age=31536000, immutable. |
always | Adds the configured header regardless of response code. | Avoid it for long-lived asset cache rules unless error responses should carry the same policy. |
expires | Adds or modifies Expires and Cache-Control. | Use it for simple max-age policies, but do not combine it with a conflicting Cache-Control header. |
ETag | Enables conditional revalidation for static resources. | NGINX enables static-file ETags by default; leave them on unless your deployment has a specific validator problem. |
The official NGINX headers module reference documents add_header and expires, while the NGINX ETag directive reference documents static-resource ETag generation. RFC 8246 defines the immutable Cache-Control extension for versioned resources.
Do not use
immutableunless the asset URL changes when the file changes. A one-year browser cache on an unversioned file can leave visitors stuck on stale CSS or JavaScript until they force-refresh or the cache entry expires.
Configure Browser Caching for Static Assets in NGINX
Make the cache policy part of the virtual host that owns the site. Avoid placing long-lived static-asset rules in a global include unless every site behind that NGINX instance uses the same versioning and deployment model.
Find and Back Up the Active Server Block
Inspect the active configuration to print the matching server_name line with its owning file marker. Replace example.com with the hostname you are configuring:
sudo nginx -T 2>/dev/null | awk '
/^# configuration file / { file=$0; next }
/^[[:space:]]*server_name[[:space:]].*example\.com/ { print file; print $0 }
'
Back up the matching server-block file before editing it. Debian and Ubuntu package layouts often use sites-available, while Fedora, RHEL-family, and nginx.org package layouts commonly use conf.d files.
sudo cp /etc/nginx/sites-available/example.com /etc/nginx/sites-available/example.com.bak
For a conf.d layout, back up that file instead:
sudo cp /etc/nginx/conf.d/example.com.conf /etc/nginx/conf.d/example.com.conf.bak
Add Static Asset Cache Locations
Add the matching locations inside the existing server block for your site. The example assumes your build writes content-hashed files under /assets/, such as /assets/app.8f3a2b1c.css. If your framework uses a different immutable asset directory, change the location ^~ /assets/ path to match that directory.
location = /index.html {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location = /service-worker.js {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location = /asset-manifest.json {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location ~* \.html$ {
try_files $uri =404;
add_header Cache-Control "no-cache";
}
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ~* \.(css|js|mjs|json|xml|svg|ico|png|jpe?g|gif|webp|avif|woff2?|ttf|otf|eot)$ {
try_files $uri =404;
add_header Cache-Control "public, max-age=86400";
}
The location ^~ /assets/ block intentionally wins before the broader file-extension regex. That prevents versioned build assets from falling through to the shorter one-day rule. If your server block has more specific static locations, compare the final order with NGINX location block priority before reloading.
The try_files checks make missing static files return 404 inside the matched location instead of falling into an application fallback. Review NGINX try_files directive behavior before adapting the snippet to a server block that already uses named locations, custom error pages, or single-page-app routing.
The long-lived locations omit the always parameter on purpose. NGINX would otherwise attach the same cache policy to 404 or 500 responses from that location, which can make browsers and intermediary caches keep a missing asset response longer than intended.
If your server block already inherits security headers from a higher level through
add_header, addingCache-Controlinside a location can stop those inherited headers from applying there. Include the security-header snippet again inside the affected location, or verify that your NGINX version is 1.29.3 or newer before relying onadd_header_inherit merge;.
Use Shorter Caching for Unversioned Static Files
The generic extension block uses one day because it may match files whose URLs do not change on deployment. Increase that value only after you confirm the application renames those files when content changes. If your application serves index.html through / or a single-page-app fallback, verify that those routes also return Cache-Control: no-cache.
Static JSON needs the same URL discipline as CSS and JavaScript. Keep json in the generic extension block for versioned build data, but give frequently changing JSON endpoints their own no-cache or short-lived policy instead of letting them inherit a day-long static-asset rule.
| Asset Pattern | Header | Reason |
|---|---|---|
Content-hashed build files under /assets/ | public, max-age=31536000, immutable | The URL changes with the content, so a long freshness lifetime is safe. |
| Plain images, CSS, JavaScript, fonts, and icons | public, max-age=86400 | A shorter lifetime limits stale files when the URL does not change. |
/index.html, service workers, and asset manifests | no-cache | The browser can store the file but must revalidate before reuse. |
| Private downloads or personalized files | private, no-cache or no-store | Public cache rules do not belong on user-specific responses. |
Test and Reload the NGINX Configuration
Test the merged configuration before touching the running service:
sudo nginx -t
A successful syntax test prints these lines:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload NGINX only after the syntax test passes:
sudo systemctl reload nginx
If your installation does not use systemd, reload NGINX with the service manager or process supervisor that owns the running instance. The important sequence is the same: syntax test first, reload second, response-header verification last.
Verify Browser Cache Headers with curl
Use curl to inspect the response that reaches a client. The --noproxy '*' option avoids local proxy environment variables during loopback or origin checks, while -D - -o /dev/null prints response headers without the response body. For broader request examples, see the curl command guide.
If a CDN, load balancer, or proxy manager sits in front of NGINX, run the public hostname check first because that is what browsers receive. For an origin-only check, preserve the same host and HTTPS behavior with a targeted resolver override such as curl --resolve only when you know the correct origin IP.
Check a versioned asset URL first:
curl --noproxy '*' -fsS -D - -o /dev/null https://example.com/assets/app.8f3a2b1c.css | sed -n '/^Cache-Control:/Ip'
Relevant output should include the long immutable policy:
Cache-Control: public, max-age=31536000, immutable
Check a normal static file that does not use a content-hashed URL:
curl --noproxy '*' -fsS -D - -o /dev/null https://example.com/css/site.css | sed -n '/^Cache-Control:/Ip'
Cache-Control: public, max-age=86400
Check the HTML entry point, which should revalidate instead of staying fresh for a long period:
curl --noproxy '*' -fsS -D - -o /dev/null https://example.com/index.html | sed -n '/^Cache-Control:/Ip'
Cache-Control: no-cache
Check a missing file under the immutable asset path. A 404 response should not include the long-lived immutable policy:
curl --noproxy '*' -sS -D - -o /dev/null https://example.com/assets/missing.8f3a2b1c.css | sed -n '1p;/^Cache-Control:/Ip'
If that check prints Cache-Control: public, max-age=31536000, immutable on a 404 response, remove always from the long-lived asset rule or move the error handling so missing files do not inherit the asset cache policy.
Use the NGINX expires Directive When Needed
The expires directive is convenient when you want NGINX to emit both an Expires header and a matching Cache-Control: max-age value. It cannot add immutable, so use add_header Cache-Control when you need a complete modern policy.
Use this pattern as an alternative for the same asset class, not as an extra duplicate regex location. If two regex locations can match the same CSS or JavaScript request, NGINX uses the first matching regex location it reaches.
location ~* \.(css|js|mjs|png|jpe?g|gif|webp|avif|svg|woff2?)$ {
try_files $uri =404;
expires 7d;
}
For a seven-day policy, NGINX sends a future Expires timestamp and Cache-Control: max-age=604800. Keep this pattern separate from custom Cache-Control values so the response does not contain duplicate or contradictory cache instructions.
After reloading, verify a file matched by the expires location. The Expires timestamp changes with the current date, so the stable value to check is the generated max-age:
curl --noproxy '*' -fsS -D - -o /dev/null https://example.com/css/site.css | sed -n '/^Cache-Control:/Ip'
Cache-Control: max-age=604800
Troubleshoot NGINX Browser Caching Problems
Cache-Control Header Is Missing
A missing header usually means the request matched a different server block, a different location, or an upstream/CDN layer replaced the origin response. Start with the response header check, then inspect the active NGINX configuration:
sudo nginx -T 2>/dev/null | awk '
/^# configuration file / { file=$0; next }
/^[[:space:]]*(add_header[[:space:]]+Cache-Control|expires[[:space:]])/ { print file; print $0 }
'
If the expected rule is not in the active dump, the file is not included or the edit was made in the wrong server block. If the rule is present, test the exact URL and host that the browser uses, including HTTPS, CDN hostnames, and any proxy manager in front of NGINX.
Security Headers Disappear on Static Assets
NGINX inherits add_header directives only when the current level defines no add_header directives of its own. A static asset location that adds Cache-Control can therefore drop server-level security headers unless they are included in the same location.
Verify the affected URL before changing the policy:
curl --noproxy '*' -fsS -D - -o /dev/null https://example.com/assets/app.8f3a2b1c.css | sed -n '/^Cache-Control:/Ip;/^Strict-Transport-Security:/Ip;/^X-Content-Type-Options:/Ip'
If security headers are missing only on static assets, include the same security-header snippet inside those asset locations or use a verified add_header_inherit merge; setup on NGINX 1.29.3 or newer. The separate NGINX security headers guide covers those header policies in more detail.
Visitors Still See Old CSS or JavaScript
Stale assets after deployment usually mean a long cache policy was applied to a URL that did not change. Check the cached URL in the browser developer tools or with curl, then compare it with the filename generated by the current build.
Fix the deployment path, not the visitor’s browser. Use content-hashed filenames, move immutable caching to the build output directory only, and keep index.html or the application shell on no-cache so it can point clients at the new asset URLs.
NGINX Reports location Directive Is Not Allowed Here
The error means the static-asset locations were placed in the wrong context, usually inside http or an auto-loaded file that is not included from a server block. Move the location blocks into the site server block, then retest:
sudo nginx -t
Use NGINX access and error logs when the syntax test passes but requests still match the wrong route, return an unexpected status, or appear to come from a CDN instead of the origin.
Rollback Browser Cache Changes
If the new policy causes stale assets, missing security headers, or bad route matches, restore the backed-up server-block file and retest before reloading.
sudo cp /etc/nginx/sites-available/example.com.bak /etc/nginx/sites-available/example.com
sudo nginx -t
sudo systemctl reload nginx
For a conf.d layout, restore the matching backup instead:
sudo cp /etc/nginx/conf.d/example.com.conf.bak /etc/nginx/conf.d/example.com.conf
sudo nginx -t
sudo systemctl reload nginx
After rollback, repeat the curl checks for one versioned asset, one ordinary static file, and index.html. Browser caching is ready only when the response headers match the deployment model instead of merely passing nginx -t.
Conclusion
NGINX can now send browser cache headers that match how the site deploys static assets: long-lived immutable caching for versioned build output, short caching for ordinary files, and revalidation for HTML entry points. Keep the policy aligned with future build paths, and pair it with compression, sendfile, and open file cache only where those separate optimizations fit the same static-file workload.


Formatting tips for your comment
You can use basic HTML to format your comment. Useful tags currently allowed in published comments:
<code>command</code>command<strong>bold</strong><em>italic</em><a href="https://example.com">link</a><blockquote>quote</blockquote>