Binding My App to localhost Broke the Site with a 500: A Next.js Middleware + -H 127.0.0.1 Postmortem
During a security review I did something utterly routine. Several web apps sitting behind a reverse proxy were bound directly to the external interface (0.0.0.0), so I started switching them, one at a time, to listen only on 127.0.0.1. The first app flipped cleanly. On the second, local requests returned 200 but requests through the domain returned 500. I almost waved it off as "a restart timing thing" — but it was a deterministically reproducible bug. This is the story of tracking down that 500: the trap that appears where a Next.js i18n middleware meets -H 127.0.0.1.
1. Background: wasn't this just "bind it to localhost"?
The layout is a common one. nginx terminates TLS on 443 and proxies to backend apps (Next.js, Flask, and so on, each on its own port) by path or host. If an app itself is open on 0.0.0.0, it can be reached directly by port whenever the firewall is loose, so binding the app to the loopback interface only — "reachable through the proxy alone" — is textbook defense.
In Next.js you just add -H (host) to the start command.
# Before
next start -p 3002
# After
next start -p 3002 -H 127.0.0.1
The first app switched over, the listener moved to 127.0.0.1, and the domain still returned 200. Done. Then the second app cracked.
2. Symptom: 200 locally, 500 through the proxy
Same change, different outcome.
curl http://127.0.0.1:3002/ → 200 (local, direct)
curl https://example.com/ → 500 (via nginx)
My first suspicion was "warm-up right after restart" — the classic misdiagnosis. So I reproduced it locally by mimicking the exact headers the proxy adds.
curl -H "Host: example.com" \
-H "X-Forwarded-Proto: https" \
http://127.0.0.1:3002/ → 500 (reproduced!)
Not timing. The moment Host and X-Forwarded-Proto arrive like they would from the proxy, it's a 500 every time. A bug you can reproduce on demand is already half solved.
3. Root cause: the "host" the middleware saw had changed
The app in question handled i18n routing in middleware. When a request comes in without a locale prefix, it prepends the default locale and does an internal rewrite — a very common pattern.
export function middleware(request) {
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
return NextResponse.rewrite(url); // internal rewrite
}
The crux is what host request.nextUrl carries. When the server is started with -H 127.0.0.1, the host on the nextUrl the middleware sees is normalized not to the real request Host (example.com) but to the localhost:port the server is bound to. So the rewrite target built from url.clone() ends up with a host that differs from the request host.
From Next.js's point of view, if a rewrite target's host differs from the current request host, that is not an "internal path rewrite" but a "rewrite to an external URL." The server then tries to fetch itself at something like https://localhost:3002/... (where there is no TLS, and no such server), that fetch breaks, and out comes the 500.
Why was local curl 200? Hitting it locally without a Host header means the nextUrl host and the request host are both localhost anyway, so no mismatch arises. The mismatch happens only when the proxy carries the real domain Host. That's why "passed local testing" was no basis for confidence.
4. The quiet twin: what if it had been a redirect, not a rewrite?
The scarier part was this code's cousin. A neighboring app used the same i18n middleware but called NextResponse.redirect(url) instead of rewrite.
A rewrite that breaks internally at least throws a 500. A redirect sends that wrong host straight to the browser. Had I simply flipped that app to -H 127.0.0.1 and shipped it, visitors would have been bounced to https://localhost:3002/... — an outage quieter and worse than a 500. A 500 is at least loud.
5. Fix: restore the original request host
The fix point was clear. When building the rewrite/redirect URL, replace the localhost host the server normalized in with the host and protocol from the original request. nginx was already passing Host and X-Forwarded-Proto, so use those.
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
// Restore the localhost host (normalized behind the proxy) to the original request values
const host = request.headers.get('x-forwarded-host')
?? request.headers.get('host');
const proto = request.headers.get('x-forwarded-proto');
if (proto) url.protocol = proto;
if (host) {
url.host = host;
// Trap: the host setter does NOT clear the existing port.
// If the Host header has no port, clear it explicitly so :3002 isn't appended.
if (!host.includes(':')) url.port = '';
}
return NextResponse.rewrite(url);
Those last three lines are the second trap. Even if you assign "example.com" to the URL.host setter, the :3002 already baked into the object does not vanish on its own. Fail to clear the port and you get https://example.com:3002/, and the same symptom returns.
After fixing the middleware and rebuilding, both apps confirmed -H 127.0.0.1 binding plus 200 through the domain.
6. Lessons
- "Transient" is not a diagnosis. Had I blamed restart timing and rolled back, the cause would never have surfaced. When a 500 appears, reproduce it deterministically — mimic the headers if you must — before you conclude anything.
- A local 200 does not guarantee the proxied path. When the
HostandX-Forwarded-*the proxy carries change app behavior, you must verify through the real path that passes the proxy. - rewrite and redirect fail differently. Same bug, but rewrite yields a 500 (server-internal) while redirect ships the user to the wrong host. The latter is quieter and more dangerous.
- The bind host changes how the app perceives its "own address."
-H 127.0.0.1doesn't only narrow the network; it also affects the base host the framework uses when constructing absolute URLs. - A URL object's host setter doesn't clear the port. A small but recurring trap. When you change the host, tidy up the port too.
FAQ
Q. Why bind the app to localhost when you could just block the port with a firewall?
A firewall (cloud security group, etc.) and app binding are defenses at different layers. If a firewall rule is accidentally opened, or once you account for access from other processes on the same host, binding the app to loopback only fits defense in depth. Doing both is the standard.
Q. Was there a way around it without touching the middleware?
You could in theory work around it by adjusting proxy_set_header Host in nginx or tweaking basePath/absolute-URL settings in the app, but the root cause is that "the middleware ignores the request host when building absolute URLs." Fixing it at the source had the fewest side effects.
Q. Does every Next.js app hit this?
No. It surfaces in apps that build absolute URLs with nextUrl.clone() to rewrite/redirect in middleware, while also running behind a proxy with -H 127.0.0.1. Apps that only deal in relative paths, or have no middleware, won't show the symptom.
This post generalizes a problem encountered during real server maintenance. Specific infrastructure details — hostnames, paths, credentials — have been deliberately omitted.
