A bare proxy_pass line will move traffic, and it will also quietly hand your application the wrong client IP, the wrong scheme, and a half-broken WebSocket handshake. The backend then logs your proxy’s own address as every visitor, builds redirects to http:// from behind your TLS terminator, rate-limits everyone as a single client, and drops real-time connections after the opening frame. None of this shows up in a smoke test. All of it shows up in production.
The fix is not clever. It is a small set of headers and a few directives that most tutorials leave out because the tutorial only had to serve one GET /. Here is the whole thing, for an upstream app on nginx 1.26, with the reasoning attached so you can adapt it rather than cargo-cult it.
What a minimal proxy_pass actually drops
When nginx proxies a request, it opens a fresh connection to the upstream and writes a new request. Unless you tell it otherwise, the client’s IP address, the original Host, and the fact that the outside connection was HTTPS all stop at nginx. The app sees a plaintext request from 127.0.0.1 with whatever Host nginx felt like sending. Frameworks that build absolute URLs, set secure-cookie flags, or trust a forwarded-proto header to decide “am I behind TLS” get all of that wrong. Authentication flows are especially unforgiving here: a login redirect built to http:// or against the wrong host will fail in ways that look like an app bug but are a proxy bug. The same header hygiene matters when the app is validating JWTs and checking issuer and audience derived from the request URL.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
The server block, in full
# /etc/nginx/conf.d/app.conf
upstream app_backend {
server 127.0.0.1:8080;
# Pool of idle connections kept open to the upstream.
keepalive 32;
}
# Map the incoming Upgrade header to the Connection header we send upstream.
# WebSocket requests get "upgrade"; everything else gets an empty value so the
# keepalive pool above is reused instead of being closed after each response.
map $http_upgrade $connection_upgrade {
default upgrade;
'' "";
}
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/ssl/certs/app.example.com.crt;
ssl_certificate_key /etc/ssl/private/app.example.com.key;
location / {
proxy_pass http://app_backend;
# Required for both keepalive reuse and WebSocket upgrades.
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
# Tell the app who the client is and how they actually connected.
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;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Timeouts. nginx defaults to 60s for connect, send and read.
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Response buffering is on by default; keep it unless you stream.
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
}
Run nginx -t before every reload. This file belongs in version control and in review like any other code; a pre-commit baseline that actually runs can lint it and block a broken config before it reaches the box.
The headers, and why each one is there
Host $host. Preserves the hostname the client asked for, so the app’s routing, virtual hosts and generated links match the public name rather than the upstream socket. Use $host rather than $http_host; $host falls back to server_name when no Host header is sent, which avoids an empty value.
X-Forwarded-For $proxy_add_x_forwarded_for. This appends the client’s address to any existing chain rather than overwriting it, which is what you want when there is more than one proxy in front. Your app must be configured to trust this header only from the proxy’s address, or a client can spoof its own IP by sending the header directly.
X-Forwarded-Proto $scheme. The single most common cause of redirect loops and mixed-content warnings behind a TLS terminator. The app terminates nothing itself, so this header is how it learns the outside connection was HTTPS and stops rewriting links to http://.
Keepalive, timeouts and the WebSocket trap
The keepalive directive in the upstream block holds a pool of idle connections open so nginx is not paying for a fresh TCP and TLS handshake on every request. It only works if the proxied request uses HTTP/1.1 and does not send Connection: close, which is why the location sets proxy_http_version 1.1 and a managed Connection header.
That managed header is where keepalive and WebSockets collide. The canonical WebSocket example in the nginx docs maps a non-upgrade request to Connection: close, which tears down the connection after every ordinary response and defeats your keepalive pool. The map above sends an empty Connection value for normal requests instead, so the pool survives, and Connection: upgrade only for genuine WebSocket handshakes. You get connection reuse and working real-time sockets from one block.
The timeouts default to 60 seconds each. proxy_read_timeout is the gap allowed between two successive reads from the upstream, not a total request budget, so a long-lived WebSocket or a slow streaming endpoint will hit it and be cut. Raise it deliberately for those locations rather than globally, and lower proxy_connect_timeout so a dead backend fails fast instead of hanging clients for a minute.
The trailing slash that rewrites your paths
One character in proxy_pass changes what the backend receives. With a URI on the target (a trailing slash counts), nginx replaces the matched location prefix; without one, it passes the path through untouched.
location /api/ {
proxy_pass http://app_backend/; # /api/users -> /users
}
location /api/ {
proxy_pass http://app_backend; # /api/users -> /api/users
}
Note that when you proxy to a named upstream you cannot add a URI, so the second form is what you get with the block above and the full path reaches the app. If you need the prefix stripped, proxy to an explicit address with a trailing slash, or rewrite before the proxy_pass.
Proving the headers actually arrive
Do not assume. Point a throwaway listener at the upstream port and read the raw request nginx forwards.
# Terminal 1 - dump the exact request nginx sends upstream:
ncat -l 8080
# Terminal 2 - hit nginx from outside:
curl -sk https://app.example.com/ -o /dev/null
# Terminal 1 now shows the forwarded request, e.g.:
# GET / HTTP/1.1
# Host: app.example.com
# Connection:
# X-Real-IP: 203.0.113.9
# X-Forwarded-For: 203.0.113.9
# X-Forwarded-Proto: https
The listener does not reply, so curl will hang until you stop it; that is fine, you already have what you needed. If X-Forwarded-Proto reads https and X-Forwarded-For carries the real client address, the app has what it needs to log, redirect and rate-limit correctly. Once it is right, keep it right: proxy misconfiguration is a quiet contributor to change-failure rate, and it is worth watching alongside the rest of your delivery and reliability metrics. A reverse proxy that lies to the application is a bug you will debug at the wrong layer for hours.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Can you trust the architecture you have?
Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.