nginx sub_filter Strips Cache Validators: Why SPA Entries Need Explicit Cache-Control
This article explains how nginx's sub_filter module removes Last-Modified and ETag headers during HTML replacement, why heuristic caching can still serve stale SPA entry pages, and how to explicitly set Cache-Control: no-cache while preserving security headers and retaining old hashed assets during deployments.
What sub_filter Does
The sub_filter directive, part of the ngx_http_sub_module (source file ngx_http_sub_filter_module.c), performs case-insensitive string replacement in response bodies. It does not parse HTML or use regular expressions. A common use case is injecting a site-specific domain into shared front-end builds at serve time:
<meta property="og:image" content="__SITE_ORIGIN__/share.png">
<meta name="twitter:image" content="__SITE_ORIGIN__/share.png">nginx replaces the placeholder before sending the HTML:
location / {
sub_filter '__SITE_ORIGIN__' 'https://$site_host';
sub_filter_once off;
try_files $uri $uri.html /index.html;
} sub_filter_oncedefaults to on (replace only the first match per rule); off is used here because the placeholder appears in two tags. The module processes text/html by default and is not compiled into nginx by default — verify with nginx -V. The variable $site_host must be defined safely (e.g., from an allow-list) to avoid injecting attacker-controlled domains into share tags.
Why HTML Response Headers Change
Because the response body may be altered, the original static file's length and validators no longer match the final output. According to current nginx source code, for any main request that enters the sub_filter processing pipeline, the header filter unconditionally removes Content-Length; by default it also removes Last-Modified and ETag.
Two important details:
The cleanup happens during the header-filter phase, before the body is scanned. Therefore, headers are stripped even if the placeholder is not actually found — it is not true that headers are removed only when a replacement occurs.
Enabling sub_filter_last_modified on does not simply restore all original validators. The directive preserves the original Last-Modified and converts any strong ETag (e.g., "abc") into a weak one ( W/"abc"). It does not generate a new ETag based on the modified body, and Content-Length remains removed.
A weak ETag does not guarantee byte-for-byte equivalence. If the replacement value changes while the underlying file stays the same, the validator may not reflect that change. sub_filter does not remove Cache-Control. The absence of a cache policy on HTML responses must be traced to add_header configuration, inheritance rules, or other settings — not to the body replacement itself.
No Last-Modified Does Not Mean No Caching
HTTP caching involves two distinct mechanisms:
Freshness — how long a cached response can be reused without checking the server (e.g., Cache-Control: max-age=600).
Validators — ETag and Last-Modified that allow a cache to ask the server whether its stored copy is still valid, potentially receiving a 304 Not Modified.
When explicit freshness is absent, caches may apply heuristic freshness. RFC 9111 (§4.2.2) notes that a cache can use Last-Modified; a common heuristic is 10% of the time since last modification, but the standard does not mandate this algorithm for all caches. Thus, a file modified ten days ago might be considered fresh for one day by some caches — this is only an example, not universal browser behavior.
Conversely, removing Last-Modified does not guarantee that browsers or proxies will revalidate on every request . It merely removes one common heuristic input; different cache implementations may still serve stale content using other strategies. Relying on this side effect as a caching strategy is risky: later removing the body replacement or changing validator settings could silently alter caching behavior without any explicit configuration expressing the original intent.
Why SPAs Are Especially Vulnerable
Single-page application entry HTML typically references content-hashed assets:
<!-- example filename, not from a real project -->
<script src="/assets/main.oldhash.js"></script>After a new deployment, the new HTML points to new filenames. If a browser still holds the old HTML while the old asset files have been deleted from the server, those asset requests fail. The failure occurs specifically when both conditions hold: the cached HTML is stale and the referenced resources have been removed. Merely having a cache entry does not guarantee a blank screen.
Beyond cache policy, deployments should retain old hashed assets for a grace period. Already-open pages may later load dynamic modules from the old version; the entry HTML's no-cache cannot retroactively fix those subsequent requests.
Make the HTML Cache Policy Explicit
For public SPA entry points that must fetch new versions promptly, set an explicit directive:
# Example config — replace paths with actual deployment paths.
location / {
# File must exist and contain this project's security header config.
include /etc/nginx/snippets/security-headers.conf;
add_header Cache-Control "no-cache" always;
sub_filter '__SITE_ORIGIN__' 'https://$site_host';
sub_filter_once off;
try_files $uri $uri.html /index.html;
} no-cachepermits storing the response but requires successful validation before reuse. It differs from no-store, which forbids storage entirely. When no validator is available, the cache typically re-fetches the full HTML; for small entry files this cost is usually acceptable (RFC 9111 on no-cache).
The include for security headers is deliberate: under nginx's default inheritance rules, defining any add_header in the current location blocks inheritance of add_header from parent contexts. Adding a cache header could therefore unintentionally drop CSP and other security headers. Since nginx 1.29.3, add_header_inherit merge can adjust this behavior, but verify the running version and existing configuration to avoid duplicate headers (nginx official header docs).
Hashed assets can use long-term caching in a separate resource location:
add_header Cache-Control "public, max-age=31536000, immutable";This requires that the same path never serves different content. Missing resources must return a genuine 404 — not fall back to HTML — otherwise the fallback could be cached long-term. Long-cache policies should apply only to successful resource responses.
Pages containing personal data or requiring authentication need separate private or no-store policies; do not reuse the public entry configuration.
How to Verify the Configuration
First, confirm the running nginx version, compile-time options, and configuration syntax:
nginx -v
nginx -V
nginx -tThen inspect the actual response with a GET request (example domain and path):
curl -sS -D headers.txt -o page.html https://example.com/appVerify that the HTML returns Cache-Control: no-cache, security headers are still present, and the placeholder has been replaced. Check /index.html, deep SPA routes, and hashed assets separately, as they may match different location blocks.
When a CDN is in front, observe both the origin and the public edge. Newly added headers do not automatically invalidate already-fresh cached entries; existing caches must be purged using platform-specific mechanisms.
If replacement appears to fail, check compression. sub_filter does not decompress gzipped bodies on its own. When proxying upstream, request uncompressed content with proxy_set_header Accept-Encoding "";. If gzip_static is enabled, ensure the HTML is not served from a pre-compressed file that bypasses the replacement filter. Always validate both headers and actual body content — do not rely on configuration text alone.
When the configuration is generated via a shell heredoc, distinguish two variable layers: nginx variables (e.g., $site_host) must be escaped as \$site_host so they survive to runtime, while shell variables that should be expanded immediately are left unescaped. This depends on whether the heredoc uses a quoted delimiter; do not mechanically backslash every dollar sign.
What This Incident Reveals
The fact that a browser restart resolved the issue suggests client-side state was involved, but it does not by itself prove the stale HTML came from memory cache, nor does it rule out Service Workers, page restoration, proxies, or other factors. Root-cause analysis still requires examining the Network log, response sources, and resource requests at the time of failure.
Therefore, the stale page is not attributed solely to
sub_filter</sub>. The clearer takeaway is: <strong>body replacement and cache policy must be expressed independently.</strong> <code>sub_filtermodifies content; Cache-Control defines how the response may be reused; deployment strategy ensures that old pages can still locate the resources they reference.
References
nginx: ngx_http_sub_module directive documentation
nginx: sub_filter module official source code
nginx: add_header and response header inheritance rules
RFC 9111: HTTP Caching
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Ops Development & AI Practice
DevSecOps engineer sharing experiences and insights on AI, Web3, and Claude code development. Aims to help solve technical challenges, improve development efficiency, and grow through community interaction. Feel free to comment and discuss.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
