Article

Why a Next.js Middleware Rewrite Returns 500 Behind a Reverse Proxy

A production-only 500, a green staging run, and a one-line fix: how X-Forwarded-Proto turns a Next.js middleware rewrite into a TLS handshake against a plaintext port — and how to reproduce and prevent it.
July 14, 2026
Topics:Deployment
Tags:Next.jsCaddyNode.js

The symptom: green on staging, 500 in production

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 clue: a TLS handshake against a plaintext port

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.

The root cause: X-Forwarded-Proto plus an origin-relative rewrite

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.

The fix: keep the loopback on HTTP

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.

Reproducing it before you ship

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

Three takeaways

  • A middleware rewrite behind a TLS-terminating proxy must pin the internal protocol to HTTP. Anything derived from X-Forwarded-Proto will try to make the loopback speak TLS to a plaintext port.
  • Health checks should exercise the real request path. Ours hit the app directly over HTTP, so staging could not catch a bug that only appears through the reverse proxy. A check that goes through the proxy — or simply sends X-Forwarded-Proto: https — would have failed in staging instead of production.
  • Automatic rollback and a strict production health check earn their keep. A 500-only gate caught what looser checks waved through, and rollback kept the previous release serving while we diagnosed. The incident cost minutes of a red deploy, not an outage.

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.