When your reverse proxy becomes the problem
The bug report was a masterpiece of vagueness: “notifications aren’t loading.” Brilliant. Cheers for that. So I did what anyone would and started rounding up the usual suspects — a race condition in the Vue component, a busted websocket upgrade, maybe a Kubernetes ingress doing something stupid. Then I cracked open Chrome DevTools and there it was: ERR_HTTP2_PROTOCOL_ERROR on an SSE endpoint. The connection would open, sit there looking pleased with itself for about a second, and then just die.
That little error kicked off a two-week investigation that ended with me gleefully deleting 400 lines of NGINX config and shipping a noticeably cleaner deployment. Here’s what broke, why, and what I had to rebuild once the safety blanket was gone.
The setup
The app is a Nuxt 3 thing running on Nitro. The container ran two processes side by side: NGINX on port 3000 (the public-facing one), and the Node.js server on port 3003. NGINX did the compression, the static asset caching, SSL termination, and proxied everything else through to Node.
NGINX in the same container feels sensible: you get compression and caching without standing up a separate sidecar. But it turns out the in-container proxy model carries a bunch of hidden costs that stay politely invisible right up until you try to do anything non-trivial. Then they all show up at once.
Failure mode 1: SSE and compression really don’t get along
Quick refresher, because it matters. Server-Sent Events work by holding an HTTP connection open and dribbling out newline-delimited chunks. The client connects once; the server just keeps writing; the client handles each chunk as it lands. Simple, lovely, effective.
Compression stamps all over that contract. A streaming compressor buffers chunks so it can squeeze out a better ratio — which is exactly the right instinct for a normal response body and exactly the wrong one for a stream. From NGINX’s point of view it’s just compressing a response. From SSE’s point of view, the server wrote a chunk and the client got nothing, because the chunk is sitting in a compression buffer waiting for more data that, for a slow event stream, may not turn up for ages.
(I originally pinned this entirely on Brotli, but that’s not quite fair — the real culprit is response buffering generally, NGINX’s own proxy_buffering plus whatever the compression module is holding onto. gzip will do the same thing to you. Brotli’s just where I happened to first notice it.)
The result was that ERR_HTTP2_PROTOCOL_ERROR — Chrome’s lovely catch-all for “the server did something to the HTTP/2 stream that I refuse to dignify with a specific message.” Connection opens, server writes the first event, NGINX swallows it, and after the timeout the whole thing collapses in a heap.
The first fix I tried: set Content-Encoding: identity on SSE responses to tell NGINX not to compress them. And it worked! But I want to be honest that this is a bit of a dirty trick — identity is really specified for the Accept-Encoding request header, and the spec actually says it shouldn’t show up in a Content-Encoding response header at all (MDN doesn’t even list it as a valid value). It happens to work because NGINX reads it as “nothing to do here.” Pragmatic, not clean. And worse, it was a workaround stacked on a workaround — now I had a custom response header living in my business logic whose entire job was to placate a proxy running in the same container.
The NGINX config also had a dedicated location block for the SSE endpoint with gzip off, proxy_buffering off, and X-Accel-Buffering: no (and if you go down this road, you’ll likely also want proxy_http_version 1.1 and a generous proxy_read_timeout — SSE connections are long-lived and the defaults will guillotine them). Configured correctly, sure.
Failure mode 2: SuperTokens hands back a native Response object
When I started pulling the compression logic into Nitro itself — as a beforeResponse hook — I tripped over something subtler.
SuperTokens, the auth library we use, runs its own request-response cycle internally. It takes a request, does its session magic, and hands back a fully-formed Response object — not a plain JSON body, an actual native Web API Response instance, with its own headers, status, and body stream.
My first compression plugin looked roughly like this:
nitroApp.hooks.hook("beforeResponse", (event, response) => {
const method = getAnyCompression(event);
if (method) {
compress(event, response, method);
}
});
function compress(event, response, method) {
if (typeof response.body === "string") {
// compress string
} else {
// naive fallback
sourceStream = Readable.from([JSON.stringify(response.body)]);
}
// ...
}
When response.body was a native Response object, JSON.stringify cheerfully turned it into "{}" — an empty object. The auth response body got serialized into nothing, the SuperTokens session data evaporated, and users would appear to log in fine and then immediately faceplant into auth errors. Great fun to debug.
The fix was to be explicit about it: check for the native Response and bail out early, untouched.
function compress(event, response, method) {
if (response.body instanceof Response) {
// SuperTokens and similar auth middleware return native Response objects.
// Do not attempt to compress or re-serialize these.
return;
}
// handle string and plain object bodies
}
The general lesson, and it’s one I keep relearning: when you write a beforeResponse hook that handles arbitrary responses, you have to enumerate the types you actually understand and explicitly wave through the ones you don’t. “Handle everything” is a comforting lie that shatters the instant some library brings its own response type to the party.
Failure mode 3: cluster mode and the one-time setup that ran N times
With NGINX gone, I switched Nitro from the node_server preset to node_cluster to win back the CPU concurrency NGINX’s worker model had been quietly giving me. (Nitro’s docs write the preset names with underscores — node_server, node_cluster — though it normalizes the hyphenated spellings too) The cluster preset spawns one Node worker per CPU core, which is exactly right for a compute-heavy app.
But I’d been running a single-process server for months, and the startup code had absolutely no idea it might suddenly have siblings. When the server boots, Nitro runs its plugins. Mine did:
- Run database migrations (Drizzle)
- Seed feature flags and LLM prompts
- Set up Temporal workflow schedules
In cluster mode, every worker ran all of that at the same time. Database migrations are emphatically not safe to run concurrently — two workers trying to apply the same migration file at once will either hit a lock or, on a really bad day, half-apply it twice. Temporal schedule setup has its own idempotency needs. The symptom was startup crashes and duplicate schedule registrations.
The fix is one line, but the reasoning is the bit worth pocketing:
import cluster from "node:cluster";
export default defineNitroPlugin(async () => {
if (!cluster.isPrimary) {
return;
}
await runMigrations();
await seedFeatureFlags();
await setupTemporalSchedules();
});
Here’s the genuinely handy property of cluster.isPrimary: it’s true even when you’re not running a cluster at all. A plain single-process Node app is always the “primary” as far as the cluster module is concerned. So this one guard does the right thing in both worlds — you don’t need separate code paths for clustered and non-clustered deployments.
The workers still connect to the database and do their actual jobs — they just skip the one-time setup that only the primary has any business running.
What NGINX had been quietly covering for
Ripping out the proxy surfaced a few assumptions the codebase had been making without telling anyone.
The most concrete one: Node 24 changed how FormData deals with file streams. The old code used a hack — bolting a stream onto FormData with Symbol.toStringTag trickery — that Node’s native fetch had started flat-out rejecting. NGINX never touched the request body on its way in, so this had been invisible the whole time. With direct Node handling, the FormData serialization broke and document uploads stopped working. (For the curious: Node 24 ships undici 7, which tightened the isBlobLike() check. In Node 22 you could pass a pseudo-File with the right Symbol.toStringTag and undici would stream it; in Node 24 it has to actually be a Blob or it gets coerced into nonsense.)
The fix was to buffer the file stream into memory before building the Blob:
const chunks: Buffer[] = [];
for await (const chunk of fileStream) {
chunks.push(Buffer.from(chunk));
}
const fileBuffer = Buffer.concat(chunks);
const fileBlob = new Blob([fileBuffer], { type: contentType });
formData.set("files", fileBlob, fileName);
Not elegant, and worth being clear-eyed about the tradeoff: buffering the whole file into memory gets you correctness but loses streaming, so if you’re dealing with genuinely large uploads you’re now holding the entire thing in RAM. For our document sizes that’s a fine deal; for multi-gigabyte uploads you’d want to think harder. Either way, this was always the correct behavior — the proxy had just been kindly masking a latent bug.
CORS headers were another quiet dependency. The NGINX config had been normalizing and injecting proxy headers (X-Forwarded-For, X-Forwarded-Proto, and friends) that Nitro’s request handling expected to see. With the proxy gone, those stopped arriving and some request context went missing. The fix was to configure Nitro’s route rules directly — but the lesson is that you don’t find out what your proxy was doing for you until you take it away.
Where it landed
The Dockerfile shed the NGINX base image, all the nginx -v verification steps, the nginx user/group creation, and the two nginx config files (193 and 172 lines respectively — yes, I counted, and yes, deleting them felt great). The start.sh went from:
nginx
exec env NITRO_PORT=3003 node .output/server/index.mjs
to:
exec env NITRO_PORT=3000 node .output/server/index.mjs
That’s the whole change. One fewer process, one fewer port, one fewer configuration language to keep in my head at 2am.
In its place: a Nitro compression plugin that explicitly handles string bodies and plain object bodies, skips native Response objects, and doesn’t compress SSE streams (because SSE streams don’t go through that hook at all), plus the cluster guard in the migrations plugin and the node_cluster preset in nuxt.config.ts.
A postscript: compression didn’t stay in-process
I should be honest about how this aged, because the in-process compression plugin wasn’t the final stop. It was the right move at the time — there was no edge proxy in the picture, so the app had to own compression itself. But “the app owns compression” was always a faint smell: compression is a cross-cutting transport concern, and those tend to want to live in front of the app rather than inside it.
What changed later was that we put a proper edge proxy in front of everything — we went with Traefik — and moved dynamic compression back out of the app and onto it. The distinction that matters, and the one this whole post is really circling, is that this is a proxy at the edge, not a second process crammed into the app container. It’s the same job the in-container NGINX was doing, just without any of the shared-fate weirdness that came from running it next to the app.
So if I’m being precise about the lesson: removing the in-container NGINX was right, but “never use a reverse proxy for compression” would be the wrong thing to take away. You should — just put it at the edge, where it belongs.
The bit to take away
In-container NGINX made total sense back when Node HTTP frameworks couldn’t handle compression or clustering on their own. That era is over. Nitro’s node_cluster preset does the multi-process concurrency. Its compressPublicAssets option handles static asset compression. A beforeResponse hook does dynamic compression with type-aware logic. The job NGINX was doing has quietly moved in-process while you weren’t looking.
And the real cost of that in-container proxy was never the config file itself — it was the hidden contracts it created. Streaming protocols needed special-case exemptions. Auth middleware that owned its own response cycle needed the proxy to pass it through untouched. Every new feature that so much as breathed on HTTP semantics had to account for a layer that most of the team had genuinely forgotten was even there.
So: if you’re running NGINX in the same container as your Node app, and you’re using SSE, WebSockets, or auth middleware that manages its own response serialization — go audit those assumptions before they turn into incidents. The proxy is almost certainly doing less work than you think, and hiding more bugs than you’d like.
The one thing I’d do differently? Write the cluster.isPrimary guard from day one, before you ever flip on cluster mode. It costs you nothing — cluster.isPrimary is always true in single-process mode anyway — and it heads off a whole category of multi-process startup bugs before they’re even possible. Cheap insurance.