fix: resolve proxy gzip decoding and BACKEND_URL Docker override issues

Two bugs introduced by the Next.js proxy Route Handler:

1. ERR_CONTENT_DECODING_FAILED — Node.js fetch() automatically
   decompresses gzip/br responses from the backend, but the proxy was
   still forwarding Content-Encoding and Content-Length headers to the
   browser. The browser would then try to decompress already-decompressed
   data and fail. Fixed by stripping Content-Encoding and Content-Length
   from upstream response headers.

2. BACKEND_URL shell env leak into Docker Compose — docker-compose.yml
   used ${BACKEND_URL:-http://backend:8000}, which was being overridden
   by BACKEND_URL=http://localhost:8000 set in .mise.local.toml for local
   dev. Inside the frontend container, localhost:8000 does not exist,
   causing all proxied requests to return 502. Fixed by hardcoding
   http://backend:8000 in docker-compose.yml so the shell environment
   cannot override it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
David Parry
2026-03-11 14:57:31 +11:00
parent a3b18e23c1
commit 036c62d2c0
2 changed files with 36 additions and 24 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ services:
environment:
# Points the Next.js server-side proxy at the backend container via Docker networking.
# Change this if your backend runs on a different host or port.
- BACKEND_URL=${BACKEND_URL:-http://backend:8000}
- BACKEND_URL=http://backend:8000
depends_on:
- backend
restart: unless-stopped
+35 -23
View File
@@ -11,17 +11,20 @@
import { NextRequest, NextResponse } from "next/server";
// Hop-by-hop headers that must not be forwarded to the backend.
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
// Headers that must not be forwarded to the backend.
const STRIP_REQUEST = new Set([
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host",
]);
// Headers that must not be forwarded back to the browser.
// content-encoding and content-length are stripped because Node.js fetch()
// automatically decompresses gzip/br responses — forwarding these headers
// would cause ERR_CONTENT_DECODING_FAILED in the browser.
const STRIP_RESPONSE = new Set([
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
"content-encoding", "content-length",
]);
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
@@ -29,28 +32,37 @@ async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
const targetUrl = new URL(`/api/${path.join("/")}`, backendUrl);
targetUrl.search = req.nextUrl.search;
// Forward relevant request headers (strip hop-by-hop)
// Forward relevant request headers
const forwardHeaders = new Headers();
req.headers.forEach((value, key) => {
if (!HOP_BY_HOP.has(key.toLowerCase())) {
if (!STRIP_REQUEST.has(key.toLowerCase())) {
forwardHeaders.set(key, value);
}
});
const isBodyless = req.method === "GET" || req.method === "HEAD";
const upstream = await fetch(targetUrl.toString(), {
method: req.method,
headers: forwardHeaders,
body: isBodyless ? undefined : req.body,
// Required for streaming request bodies in Node.js fetch
// @ts-ignore
duplex: "half",
});
let upstream: Response;
try {
upstream = await fetch(targetUrl.toString(), {
method: req.method,
headers: forwardHeaders,
body: isBodyless ? undefined : req.body,
// Required for streaming request bodies in Node.js fetch
// @ts-ignore
duplex: "half",
});
} catch (err) {
// Backend unreachable — return a clean 502 so the UI can handle it gracefully
return new NextResponse(JSON.stringify({ error: "Backend unavailable" }), {
status: 502,
headers: { "Content-Type": "application/json" },
});
}
// Forward response headers (strip hop-by-hop)
// Forward response headers
const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
if (!HOP_BY_HOP.has(key.toLowerCase())) {
if (!STRIP_RESPONSE.has(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});