nginx as a Reverse Proxy: Headers, WebSockets and Timeouts
Putting nginx in front of an application server takes one line: proxy_pass. A correct setup takes a few more. The backend needs to know the real client address and the original host name. WebSockets need an explicit upgrade, and slow endpoints need sensible timeouts. And one trailing slash in the wrong place silently rewrites every URL.
To see what the backend actually receives, I wrote a small Python server that echoes the request line and the proxy headers. Then I put nginx 1.30.6 (the official nginx:stable-alpine image) in front of it. Both ran as temporary containers on a private Docker network, with nginx published on 127.0.0.1:18084.
The test backend
Standard library only, running in python:3.12-alpine. It has four behaviours: echo the request, /slow which sleeps for five seconds, /stream which sends three lines two seconds apart, and a minimal WebSocket echo.
# Tiny test backend: echoes path and headers, plus /slow, /stream, /ws
import base64, hashlib, struct, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def send_text(self, body):
data = body.encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.headers.get("Upgrade", "").lower() == "websocket":
return self.websocket()
if self.path.startswith("/slow"):
time.sleep(5)
return self.send_text("slow response done\n")
if self.path.startswith("/stream"):
return self.stream()
lines = [f"request: {self.requestline}"]
for name in ("Host", "X-Real-IP", "X-Forwarded-For",
"X-Forwarded-Proto", "Connection"):
lines.append(f"{name}: {self.headers.get(name)}")
lines.append(f"client socket: {self.client_address[0]}"
f":{self.client_address[1]}")
self.send_text("\n".join(lines) + "\n")
def stream(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Transfer-Encoding", "chunked")
if "nobuffer" in self.path:
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
for i in range(1, 4):
chunk = f"{time.strftime('%H:%M:%S')} event {i}\n".encode()
self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
self.wfile.flush()
time.sleep(2)
self.wfile.write(b"0\r\n\r\n")
def websocket(self):
key = self.headers["Sec-WebSocket-Key"]
accept = base64.b64encode(
hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
self.send_response(101, "Switching Protocols")
self.send_header("Upgrade", "websocket")
self.send_header("Connection", "Upgrade")
self.send_header("Sec-WebSocket-Accept", accept)
self.end_headers()
self.wfile.flush()
# Echo text frames (client frames are always masked)
while True:
head = self.rfile.read(2)
if len(head) < 2:
return
opcode, length = head[0] & 0x0F, head[1] & 0x7F
if length == 126:
length = struct.unpack(">H", self.rfile.read(2))[0]
mask = self.rfile.read(4)
payload = bytes(b ^ mask[i % 4] for i, b in
enumerate(self.rfile.read(length)))
if opcode == 8:
return
reply = b"echo: " + payload
self.wfile.write(bytes([0x81, len(reply)]) + reply)
self.wfile.flush()
def log_message(self, fmt, *args):
print(f"{self.address_string()} {fmt % args}", flush=True)
ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()I print self.requestline rather than self.path on purpose. Python 3.12's http.server collapses a leading // in the path, which would hide exactly the bug I wanted to show.
$ docker network create rg-net
$ docker run -d --name rg-backend --network rg-net \
-v "$PWD:/app:ro" python:3.12-alpine python -u /app/backend.py
$ docker run -d --name rg-proxy --network rg-net -p 127.0.0.1:18084:80 \
-v "$PWD/proxy.conf:/etc/nginx/conf.d/default.conf:ro" nginx:stable-alpine
$ docker exec rg-proxy nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successfulproxy_pass with and without a URI part
This is the rule that confuses people most. If the proxy_pass address has no path after the host and port, nginx passes the request URI unchanged. If it has a path, even a single /, nginx replaces the part of the URI that matched the location with that path. Three locations show the difference:
# No URI part: the request URI is passed unchanged
location /raw/ {
proxy_pass http://rg-backend:8000;
}
# URI part "/": the matched prefix /strip/ is replaced with /
location /strip/ {
proxy_pass http://rg-backend:8000/;
}
# The two-slash trap: prefix without slash, URI part with slash
location /trap {
proxy_pass http://rg-backend:8000/;
}$ for u in '/raw/users?id=7' '/strip/users?id=7' /trap/users /trapdoor; do
echo "== $u"; curl -s "http://127.0.0.1:18084$u" | head -2
done
== /raw/users?id=7
request: GET /raw/users?id=7 HTTP/1.1
Host: rg-backend:8000
== /strip/users?id=7
request: GET /users?id=7 HTTP/1.1
Host: rg-backend:8000
== /trap/users
request: GET //users HTTP/1.1
Host: rg-backend:8000
== /trapdoor
request: GET /door HTTP/1.1
Host: rg-backend:8000/raw/ passes the path as is. /strip/ swaps /strip/ for /, which is what you want when an app mounted under a sub-path expects to live at the root. /trap swaps /trap for /, so /trap/users arrives as //users, and because prefix matching is plain string comparison, /trapdoor arrives as /door. Keep the slashes symmetric: either both the location and the URI part end in a slash, or neither does.
Requesting /strip without the slash gave a 301 to /strip/. nginx does that automatically for prefix locations ending in a slash that use proxy_pass. The location matching article covers the rest of those rules.
Headers: Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto
Look at the Host line above. By default nginx sends the host and port from the proxy_pass address, rg-backend:8000, not the name the visitor typed. Applications that build absolute URLs, or pick a site by host name like WordPress multisite does, get this wrong. The backend also sees nginx as the client. The main location fixes both:
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}$ curl -s -H 'X-Forwarded-For: 203.0.113.9' http://127.0.0.1:18084/app/x
request: GET /app/x HTTP/1.1
Host: 127.0.0.1
X-Real-IP: 172.29.0.1
X-Forwarded-For: 203.0.113.9, 172.29.0.1
X-Forwarded-Proto: http
Connection: None
client socket: 172.29.0.3:40128This output shows a few things. $host is the host name without the port, so 127.0.0.1:18084 became 127.0.0.1. If your app needs the port, use $http_host instead. The client address is 172.29.0.1, the Docker network gateway, because my curl came in through a published port. On a real server it would be the visitor's address.
$proxy_add_x_forwarded_for appends the client address to whatever X-Forwarded-For the client sent. I sent a fake one, and nginx kept it. That is correct behaviour for a chain of proxies, but it means the leftmost entry is whatever the client wants it to be. Trust only the addresses added by proxies you control, which usually means the rightmost one. X-Real-IP set from $remote_addr cannot be forged this way.
X-Forwarded-Proto tells the app whether the visitor used HTTPS. Without it, an app behind a TLS-terminating nginx sees plain HTTP and generates http:// links or redirect loops.
An upstream block with keepalive
The main location points at a named upstream instead of a host:
upstream app_backend {
server rg-backend:8000;
keepalive 16;
}keepalive 16 lets each worker keep up to 16 idle connections open to the backend. It only works with HTTP/1.1 and with the Connection header cleared, which is why the location sets both. The difference is visible in the backend's view of the client port:
$ for i in 1 2 3; do curl -s http://127.0.0.1:18084/raw/x | grep socket; done
client socket: 172.29.0.3:40130
client socket: 172.29.0.3:40132
client socket: 172.29.0.3:40134
$ for i in 1 2 3; do curl -s http://127.0.0.1:18084/app/x | grep socket; done
client socket: 172.29.0.3:40136
client socket: 172.29.0.3:40136
client socket: 172.29.0.3:40136Without the upstream block, every request opened a new TCP connection. With it, all three reused one. (A different nginx worker had handled the earlier request on port 40128, and each worker keeps its own pool.) One thing surprised me: the /raw/ requests arrived as HTTP/1.1 even though that location has no proxy_http_version. Older nginx releases defaulted to 1.0, which is why every guide sets it. I still set it explicitly, so the config means the same thing on any version.
WebSockets
Upgrade and Connection are hop-by-hop headers, so nginx does not forward them unless told to. The standard method uses a map so that ordinary requests to the same location still get Connection: close:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 1h;
}The map goes in the http context, outside the server block. The curl here is too old for WebSockets, but it can send the handshake and show the response:
$ curl -s -i -m 3 -H 'Upgrade: websocket' -H 'Connection: Upgrade' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
-H 'Sec-WebSocket-Version: 13' http://127.0.0.1:18084/ws
HTTP/1.1 101 Switching Protocols
Server: nginx/1.30.6
Date: Sat, 17 Oct 2026 19:14:41 GMT
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=curl then waited on the open connection until -m 3 stopped it. The same handshake sent to /chat, which falls into the main location without the upgrade headers, came back as a plain 200 with the backend echoing request: GET /chat HTTP/1.1. The upgrade never reached it. To prove that frames really pass through, I wrote a 23-line Python client and ran it from another container on the same network:
$ docker run --rm --name rg-wsclient --network rg-net -v "$PWD:/app:ro" \
python:3.12-alpine python /app/wsclient.py rg-proxy 80 /ws "hello through nginx"
HTTP/1.1 101 Switching Protocols
received: echo: hello through nginxThe long proxy_read_timeout matters here. It applies between two reads from the backend, and a quiet WebSocket with no traffic for 60 seconds (the default) gets closed by nginx. Either raise it or have the app send pings.
proxy_read_timeout and 504s
The same timeout is what produces most 504 errors. I gave /slow, which takes five seconds, a three-second limit:
location /slow {
proxy_pass http://app_backend;
proxy_read_timeout 3s;
}$ time curl -s -i http://127.0.0.1:18084/slow | grep -E '^HTTP|title'
HTTP/1.1 504 Gateway Time-out
<head><title>504 Gateway Time-out</title></head>
real 0m3.008s
user 0m0.003s
sys 0m0.005sThe error log says which phase timed out, which is the first thing to check:
2026/10/17 19:14:47 [error] 21#21: *26 upstream timed out (110: Operation timed out) while reading response header from upstream, client: 172.29.0.1, server: _, request: "GET /slow HTTP/1.1", upstream: "http://172.29.0.2:8000/slow", host: "127.0.0.1:18084""While reading response header" means the backend accepted the request and was too slow to answer. I also stopped the backend container to see a dead upstream, expecting a 502. I got another 504, logged as upstream timed out (110: Operation timed out) while connecting to upstream. nginx had resolved rg-backend once at startup, and the stopped container's address simply stopped answering. Then the default 60-second proxy_connect_timeout ran out (curl reported real 1m0.064s). Raise proxy_read_timeout only for the locations that need it, such as report exports, rather than server-wide.
proxy_buffering and streaming responses
By default nginx buffers backend responses, so it can free the backend quickly while a slow client downloads. For server-sent events or progress output you want each chunk delivered as it arrives. I timed when each line reached curl, with gzip enabled in both locations because it is enabled on most real servers:
location /stream {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
gzip on;
gzip_types text/plain;
gzip_min_length 1;
}
location /stream-buffered {
proxy_pass http://app_backend/stream$is_args$args;
gzip on;
gzip_types text/plain;
gzip_min_length 1;
proxy_http_version 1.1;
proxy_set_header Connection "";
}$ for u in /stream /stream-buffered; do echo "== $u"
curl -sN --compressed "http://127.0.0.1:18084$u" | while IFS= read -r l; do
printf 'got at %s: %s\n' "$(date -u +%T)" "$l"; done; done
== /stream
got at 19:14:54: 19:14:54 event 1
got at 19:14:56: 19:14:56 event 2
got at 19:14:58: 19:14:58 event 3
== /stream-buffered
got at 19:15:06: 19:15:00 event 1
got at 19:15:06: 19:15:02 event 2
got at 19:15:06: 19:15:04 event 3With buffering off, each line arrived the moment the backend sent it. With buffering on, all three arrived together at the end, six seconds late. In an earlier run without gzip, the buffered location also delivered lines promptly, so on this version the combination with gzip delayed them. That is the usual setup in production, so turning buffering off for streaming locations is still the safe choice.
If the app should decide per response, it can send X-Accel-Buffering: no. My backend does that when the query contains nobuffer, and the buffered, gzipped location then streamed normally:
$ curl -sN --compressed 'http://127.0.0.1:18084/stream-buffered?nobuffer' |
while IFS= read -r l; do printf 'got at %s: %s\n' "$(date -u +%T)" "$l"; done
got at 19:15:06: 19:15:06 event 1
got at 19:15:08: 19:15:08 event 2
got at 19:15:10: 19:15:10 event 3The full list of proxy directives and their defaults is in the proxy module docs. When nginx serves PHP directly through php-fpm instead, the try_files article covers the front-controller setup.