We recently shipped an internationalization feature to our marketing site — a path-prefix language switcher that serves English at / and Spanish under /es. The mechanism is a small piece of Next.js middleware that rewrites unprefixed requests into an internal /en route tree so the URL stays clean.
It passed every gate: type-checks, unit tests, a local production build, and a full staging deploy with a green health check. We promoted the exact same commit to production. Within seconds the post-deploy health check went red — HTTP 500 on every route — and our automatic rollback restored the previous release. Production never actually went down, but the new version would not serve a single page.
The most useful and most misleading data point: a local production build of the identical commit served every route with a 200. So did staging. Only production failed. That gap is the whole story.
The application logs told the real story. Every request produced:
Failed to proxy https://localhost:3000/en Error: write EPROTO ... ssl3_get_record:wrong version number
Two things stand out. First, the app was trying to proxy a request to itself — https://localhost:3000/en. Second, EPROTO ... wrong version number is the classic signature of speaking TLS to a plaintext socket: something sent an HTTPS handshake to a port that only understands HTTP.
Our production topology is conventional. A reverse proxy terminates TLS and forwards to the Next.js standalone server over plain HTTP on localhost:3000, setting X-Forwarded-Proto: https so the app knows the original request was secure.
The middleware built its rewrite target from the incoming request's own URL:
const url = request.nextUrl.clone()
url.pathname = "/en" + pathname
return NextResponse.rewrite(url)
Behind the proxy, request.nextUrl takes its protocol from X-Forwarded-Proto (https) but its host from the plaintext bind (localhost:3000). The rewrite target therefore resolves to https://localhost:3000/en — an origin the app treats as external, so Next issues a real proxy fetch to it. That fetch speaks TLS to a port that only serves HTTP, Node throws EPROTO, and the request 500s.
Directly over HTTP — a local next start, or a staging health check hitting the app on localhost without a TLS-terminating proxy — X-Forwarded-Proto is absent, the rewrite stays same-origin, and Next handles it internally. Green. The bug is invisible unless the request arrives through the HTTPS reverse proxy.
A Next.js standalone server never terminates TLS itself; TLS is always terminated upstream. So the internal rewrite must target the protocol the server actually speaks — HTTP — regardless of the external scheme:
const url = request.nextUrl.clone()
url.protocol = "http:" // the loopback is always plain HTTP; TLS is terminated upstream
url.pathname = pathname === "/" ? "/en" : "/en" + pathname
return NextResponse.rewrite(url)
One line. With the protocol pinned to http:, the rewrite stays an internal, same-process hop, and every route returns 200 — in production, behind the proxy, exactly as it did locally.
The lesson that generalizes past this one bug: reproduce with the real request shape. Our local repro was misleading because it omitted the single header the reverse proxy adds. Replaying the request against the standalone build with that header reproduces the failure instantly:
curl -s -o /dev/null -w "%{http_code}" \
-H "X-Forwarded-Proto: https" \
http://127.0.0.1:3000/
# 500 before the fix, 200 after
X-Forwarded-Proto will try to make the loopback speak TLS to a plaintext port.X-Forwarded-Proto: https — would have failed in staging instead of production.Reverse proxies and framework middleware each make reasonable assumptions about protocol and origin. The failures live in the seam between them — and they only surface in the environment that has both.