NGINX sub_filter: Modify Responses on the Fly.
NGINX isn’t only a reverse proxy. With sub_filter, it can transform upstream HTML responses on the fly. Letting you inject CSS, modify content, or customize applications without touching their original files.
When most people think about NGINX, they think reverse proxy, TLS termination, load balancing, caching, redirects, and serving static files.
But NGINX can do something that isn’t immediately obvious:
It can modify the content of a response before sending it to the browser.
We at SNUBmonkey™ recently encountered a practical example of this while customizing the Ghost Admin sign-in/sign-out interface.
See, Ghost’s Admin interface is part of the application itself. Its HTML, CSS, JavaScript, and underlying UI structure depend on the version of Ghost being used. Modifying those internal files can therefore become problematic: an application update can overwrite the changes, alter the underlying structure, or change the selectors your customization depends on.
We wanted a persistent customization without modifying Ghost itself.
The answer was sitting in NGINX.
⚠️ Important: sub_filter Works on Upstream
Responses
Before diving into sub_filter, there’s one important distinction to understand.
sub_filter is designed to modify response bodies being processed by NGINX, most commonly when NGINX is acting as a reverse proxy in front of an upstream application.
In other words, the workflow we’re discussing is:
Browser
│
│ Request
▼
NGINX
│
│ proxy_pass
▼
Upstream application
│
│ Response
▼
NGINX
│
│ sub_filter modifies response
▼
BrowserThis is fundamentally different from NGINX simply serving a static file directly from disk.
For example, in a reverse-proxy configuration, you could apply sub_filter inside the same location block that proxies requests to the upstream application:
location / {
# Reverse proxy to the upstream application
proxy_pass http://127.0.0.1:2368;
# Modify the upstream HTML response
sub_filter '</head>' '<style>
/* Custom CSS */
</style></head>';
sub_filter_once off;
}Here, proxy_pass is what sends the request to the upstream application. NGINX then receives the application’s response, applies sub_filter to the response body, and sends the modified response back to the browser.
It does not edit the original application files.
That distinction is exactly what makes this technique useful for our Ghost Admin customization: Ghost remains untouched, while NGINX applies our customization to the response as it passes through the reverse proxy.
Enter sub_filter
NGINX provides a response-body substitution mechanism called sub_filter.
At a basic level, it works exactly as its name suggests:
sub_filter 'old content' 'new content';NGINX receives the response from the upstream application, searches the response body for the specified string, and replaces it before the response reaches the client.
For example:
sub_filter '</head>' '<style>
/* Custom CSS */
</style></head>';If the upstream application returns:
<head>
<title>Admin</title>
</head>NGINX can send the browser:
<head>
<title>Admin</title>
<style>
/* Custom CSS */
</style>
</head>The application itself remains untouched.
That’s the important part.
NGINX Becomes a Content Layer
With a normal reverse-proxy setup, NGINX simply forwards the upstream application’s response back to the browser.
Browser
│
│ HTTP request
▼
NGINX
│
│ proxied request
▼
Application
│
│ HTTP response
▼
NGINX
│
│ response forwarded
▼
BrowserWith sub_filter, NGINX can process that response before forwarding it:
Browser
│
│ HTTP request
▼
NGINX
│
│ proxied request
▼
Application
│
│ HTML response
▼
NGINX
│
│ sub_filter modifies response body
▼
BrowserThe application doesn’t need to know that anything happened.
This creates an interesting architectural possibility where NGINX can become a presentation-layer customization point between an application and its users.
Why Hide the Content-Length Header?
When an upstream server generates a response, it may provide:
proxy_hide_header Content-Length;The Content-Length header describes the size of the original response body. But if NGINX modifies that body, the original size may no longer be accurate.
Imagine the upstream application sends 20 KB of HTML and NGINX injects another 2 KB:
Original response: 20 KB
Injected content: 2 KB
─────
Modified response: 22 KBForwarding the original Content-Length: 20480 would therefore describe the wrong response size. If NGINX modifies the response and the resulting body is 22 KiB, the correct length is 22 × 1024 = 22,528 bytes.
Hiding the upstream header allows NGINX to handle the transformed response appropriately.
Why Hide the ETag Header?
The same principle applies to:
proxy_hide_header ETag;An ETag is essentially a validator for a particular representation of the resource.
Conceptually:
Ghost HTML
↓
hash / validator
↓
ETag: "abc123"If the upstream application generates HTML and NGINX subsequently changes that HTML, the representation delivered to the browser is no longer exactly the representation produced by the upstream server.
Ghost HTML
+
your injected CSS
↓
different representationThe original ETag may therefore no longer accurately describe what the client received.
Removing it avoids carrying an upstream validator across a response transformation.
What does sub_filter_once on vs
sub_filter_once off do?
The directive:
sub_filter_once on;is the default behavior.
It tells NGINX to replace only the first matching occurrence of the search string in the response.
For example:
sub_filter 'Hello' 'Hello from SNUBMONKEY';
sub_filter_once on;If the response contains:
Hello
Hello
Helloonly the first occurrence is replaced:
Hello from SNUBMONKEY
Hello
HelloWith:
sub_filter_once off;NGINX replaces every matching occurrence:
sub_filter 'Hello' 'Hello from SNUBMONKEY';
sub_filter_once off;The resulting response becomes:
Hello from SNUBMONKEY
Hello from SNUBMONKEY
Hello from SNUBMONKEYSo the distinction is simple:
sub_filter_once on; : Replace the first matching occurrencesub_filter_once off; : Replace every matching occurrence
Our Use Case:
This technique proved particularly useful for customizing Ghost Admin.
Instead of modifying Ghost’s application files, custom CSS can be injected through the reverse proxy:
proxy_hide_header Content-Length;
proxy_hide_header ETag;
sub_filter_once off;
sub_filter '</head>' '<style>
.gh-signin h1 {
/* Custom styling */
}
</style></head>';Now the customization lives in NGINX rather than inside the Ghost installation.
When Ghost is upgraded, the NGINX configuration remains separate from the application.
That gives you a much cleaner separation:
Ghost
├── Application
├── Admin
├── Official assets
└── Updates
NGINX
└── Your customization
└── Injected CSSThe Ghost installation can change independently of the customization layer.
But There's a catch
sub_filter is not an HTML parser.
This distinction matters. NGINX isn’t constructing a DOM or intelligently interpreting the HTML structure. It is performing string substitution against the response body.
If you search for:
<div class="foo">and a future application update changes it to:
<div class="foo bar">your substitution may stop matching.
This means sub_filter works best when you target something stable and predictable.
An "element" such as:
</head>is generally a much better injection point than a complicated framework-generated element.
In other words: Choose stable anchors.
Compression Matters Too
There's another consideration: response compression.
If the upstream application sends a compressed response, NGINX can’t simply search the compressed response body for HTML such as:
</head>One way to ensure NGINX receives the response uncompressed is:
proxy_set_header Accept-Encoding "";This removes the Accept-Encoding header from the request sent to the upstream, so the upstream will generally return an uncompressed response. NGINX can then apply sub_filter to the HTML before sending the response to the client.
Whether you need this depends on your existing proxy and compression configuration, so don’t blindly add it to every NGINX setup.
This Isn’t Just About Ghost
Ghost is simply the use case that led us to sub_filter, but the technique isn’t specific to Ghost.
Any application sitting behind NGINX can potentially benefit from this approach when you need to make small, predictable changes to its HTTP responses without modifying the application itself.
What You Can Do with sub_filter
You could potentially use NGINX to:
- Inject custom CSS
- Inject small JavaScript snippets
- Modify text in responses
- Add branding
- Insert banners or notices
- Replace URLs or other response content
- Patch presentation-layer issues
- Customize applications you don’t directly control
- Apply temporary compatibility fixes
This can be particularly useful when modifying the upstream application itself would be undesirable.
For complex HTML transformations, use application middleware or an HTML-aware proxy. For simple, predictable changes, sub_filter is a lightweight and effective solution.
As a reverse proxy, NGINX can sit between your application and the browser, modify the response on the fly, and leave the upstream application untouched.
Sometimes, you just need the right layer in front of it.
Thanks for reading—and as always, keep experimenting, breaking things safely, and learning.
See you on the next one.