# When your reverse proxy becomes the problem

> An in-container NGINX broke SSE streaming, corrupted auth responses, and blocked cluster mode. Why I pulled it out, what had to be rebuilt in-process, and where compression actually belongs.

Published: Mar 18, 2026
By Nikhil Verma

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:

```ts
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.

```ts
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:

1. Run database migrations (Drizzle)
2. Seed feature flags and LLM prompts
3. 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:

```ts

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`:

```ts
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:

```sh
nginx
exec env NITRO_PORT=3003 node .output/server/index.mjs
```

to:

```sh
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.
