Nginx Reverse Proxy Patterns
proxy_pass is the single biggest source of "works on dev, broken in prod" bugs. If the URI in proxy_pass has any path at all (even just /), nginx replaces the matched location prefix. If proxy_pass has no URI, nginx passes the original URI untouched. Choose on purpose, write a comment, add a request-mirror test.
Upstream pools & health checks
An upstream block groups one or more backend servers. Open-source nginx supports passive health checks (mark a server unhealthy after N failures); NGINX Plus adds active probes. In practice passive is enough for most stacks, especially when paired with a proper liveness endpoint on the backend.
upstream app_backend {
# Load-balancing method — default is round-robin.
# least_conn;
# ip_hash; # sticky by client IP; use only if you must
# hash $request_uri consistent; # cache-friendly
server 10.0.1.11:8080 weight=3 max_fails=3 fail_timeout=30s;
server 10.0.1.12:8080 weight=1 max_fails=3 fail_timeout=30s;
server 10.0.1.13:8080 backup; # only used when all primaries are down
keepalive 32; # reusable idle connections per worker
keepalive_requests 1000;
keepalive_timeout 60s;
}
And the vhost:
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection ""; # enables keepalive to the upstream
}
}
The empty Connection "" header is not cosmetic — without it, proxy_http_version 1.1 still sends Connection: close by default and your keepalive pool is wasted.
Passive health check tuning
max_fails=3 fail_timeout=30s— after 3 consecutive failures inside a 30 s window, mark unhealthy for 30 s, then re-probe on the next request.- A "failure" is a connect error, a read error, or any of the status codes in
proxy_next_upstream. The default list is network-only; for 5xx failover, extend it:
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_pass & the trailing slash
Two rules, memorize both:
- If
proxy_passends with a hostname only (no path, not even/), nginx sends the original request URI to the upstream unchanged. - If
proxy_passincludes any path — including a bare/— nginx replaces the matchedlocationprefix with that path.
# Pattern A: "reverse proxy the whole thing"
# GET /api/v1/users -> GET /api/v1/users on backend
location /api/ {
proxy_pass http://app_backend; # NO trailing slash, NO path
}
# Pattern B: "strip the prefix"
# GET /api/v1/users -> GET /v1/users on backend
location /api/ {
proxy_pass http://app_backend/; # trailing slash: replaces /api/ with /
}
# Pattern C: "mount the backend under a new prefix"
# GET /api/v1/users -> GET /internal/v1/users on backend
location /api/ {
proxy_pass http://app_backend/internal/;
}
location with proxy_pass URIs is not allowed. If your location is ~ ^/api/(.*), drop the URI from proxy_pass and use rewrite to shape the path instead. Nginx will refuse to start if you combine them.
X-Forwarded-* and Host
Every proxied request should carry its real origin. The backend needs:
- The original
Hostheader (for virtual hosting in the app). - The real client IP (for logging, rate-limit, geo).
- The original protocol (so
redirect_to(https)logic in the app does not loop).
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;
Promote these to an include file so every location { proxy_pass ... } picks them up consistently:
# /etc/nginx/snippets/proxy-headers.conf
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_http_version 1.1;
proxy_set_header Connection "";
location / {
include /etc/nginx/snippets/proxy-headers.conf;
proxy_pass http://app_backend;
}
If there's an L4 balancer in front of nginx, also set set_real_ip_from and real_ip_header so your logs and rate-limits see the real client IP and not the balancer's address.
WebSockets
WebSockets start as an HTTP/1.1 request with Upgrade: websocket. Nginx needs to be told to forward those hop-by-hop headers and not time out the long-lived connection.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name ws.example.com;
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 3600s;
proxy_send_timeout 3600s;
proxy_buffering off; # critical for bidirectional low-latency
}
}
The map block is the idiomatic way to only set Connection: upgrade when the client actually asked to upgrade — plain HTTP requests through the same location stay on keepalive.
proxy_read_timeout. If they disconnect after five minutes you are most likely hitting a layer-4 idle timeout on your cloud load balancer, not nginx.
proxy_cache, keys, revalidation
A correctly configured proxy_cache can absorb an absurd amount of origin load, but every one of the following lines matters:
proxy_cache_path /var/cache/nginx/app
levels=1:2
keys_zone=app_cache:50m
max_size=10g
inactive=24h
use_temp_path=off;
server {
# ...
location / {
proxy_cache app_cache;
proxy_cache_key "$scheme://$host$request_uri";
proxy_cache_methods GET HEAD;
# Honor upstream cache-control most of the time, but...
proxy_cache_valid 200 301 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_valid any 0; # do NOT cache anything else
# Revalidate on expiry instead of fetching fresh (saves bytes):
proxy_cache_revalidate on;
# Serve stale content while fetching an update — the single highest-ROI knob
proxy_cache_background_update on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
# Request coalescing: only one origin fetch for identical concurrent misses
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Never cache logged-in responses
proxy_cache_bypass $http_authorization $cookie_sessionid;
proxy_no_cache $http_authorization $cookie_sessionid;
add_header X-Cache-Status $upstream_cache_status;
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy-headers.conf;
}
}
Verify with:
curl -sI https://app.example.com/some/asset | grep X-Cache-Status
# HIT, MISS, EXPIRED, STALE, UPDATING, REVALIDATED, BYPASS
Cache purging
Open-source nginx has no built-in purge command. Two practical options:
rmthe cache directory — crude but deterministic.- Rename the
keys_zone(e.g.app_cache_v2) and reload; the old zone's disk files become orphans andinactive=cleans them up.
Buffering for streaming
By default, nginx buffers the full upstream response before sending it to the client. That is fine for small API replies — and wrong for Server-Sent Events, long-poll, application/octet-stream downloads, gRPC, live transcodes.
location /events/ {
proxy_pass http://app_backend;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
# For SSE also disable any intermediate compression:
add_header X-Accel-Buffering no;
}
For downloads that pass through nginx unmodified, prefer X-Accel-Redirect (internal redirect to a /protected/ location backed by root) over proxying the bytes — you keep auth in the app, and nginx does the zero-copy send. Covered in error_page & internal redirects.
auth_request subrequests
auth_request lets nginx make a subrequest to an auth service for every incoming request; if the subrequest returns 2xx, the main request proceeds, otherwise nginx returns the auth service's status. This is the pattern behind oauth2-proxy, vouch-proxy, and a thousand bespoke SSO shims.
server {
listen 443 ssl;
server_name internal.example.com;
# The auth service itself (must be accessible from nginx; not exposed externally).
location = /auth {
internal;
proxy_pass http://127.0.0.1:4180/oauth2/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Method $request_method;
}
# Everything protected by auth_request:
location / {
auth_request /auth;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_email $upstream_http_x_auth_request_email;
proxy_set_header X-Auth-User $auth_user;
proxy_set_header X-Auth-Email $auth_email;
proxy_pass http://app_backend;
# If auth returns 401, redirect to login:
error_page 401 = @needs_login;
}
location @needs_login {
return 302 https://auth.example.com/oauth2/sign_in?rd=$scheme://$host$request_uri;
}
}
Three gotchas:
proxy_pass_request_body offand the emptyContent-Length— without them, POSTs drive the auth service wild (it re-receives the whole body).- The
internal;directive on/auth— without it, a client can hit your auth endpoint directly with a crafted request and trivially bypass. auth_request_setis how you promote response headers from the auth service into variables you can forward to the backend.
Body & header limits
Defaults are low. Every real app needs to touch these:
http {
# Max request body (uploads). Default is 1M.
client_max_body_size 25m;
client_body_buffer_size 128k;
# Max header size. Default is 4k (first line) + 8k (all headers).
# JWT + SAML cookies + X-Forwarded-For chains blow this up.
large_client_header_buffers 4 16k;
# Header and body read timeouts (not the same as upstream timeouts):
client_header_timeout 20s;
client_body_timeout 20s;
send_timeout 30s;
}
If your app does huge uploads (> 100 MB), also set client_body_temp_path to a filesystem with space, and consider client_body_in_file_only on; so nginx streams to disk instead of holding in memory.
error_page & internal redirects
error_page has two distinct modes. Pointed at a URL, nginx returns a redirect. Pointed at a named location (via = @name), it rewrites internally — the client sees one response, no round-trip, and the original status can be overridden.
server {
# Serve a custom maintenance page while keeping 503 as the status:
error_page 503 = @maintenance;
location @maintenance {
root /srv/errors;
try_files /maintenance.html =503;
}
# Pretty 404 without redirecting:
error_page 404 /404.html;
location = /404.html {
root /srv/errors;
internal;
}
# Classic X-Accel-Redirect: app checks auth and returns a header, nginx serves:
location /downloads/ {
proxy_pass http://app_backend;
}
location /protected/ {
internal;
alias /srv/secure-assets/;
}
}
The backend, after auth, returns:
HTTP/1.1 200 OK
X-Accel-Redirect: /protected/big-report.pdf
Content-Disposition: attachment; filename="big-report.pdf"
Content-Type: application/pdf
and nginx serves the file from the internal location with zero backend bandwidth.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Backend sees / instead of /api/v1/users |
proxy_pass has a path (even just /) and is stripping the prefix. |
See trailing slash rules. Remove the path to forward verbatim. |
| WebSocket connection closes after 60 seconds | Default proxy_read_timeout 60s. |
Set proxy_read_timeout 3600s in the /ws/ location. |
upstream sent too big header while reading response header |
Backend response headers exceed proxy_buffer_size. |
proxy_buffer_size 16k; proxy_buffers 8 16k; |
413 Request Entity Too Large on upload |
client_max_body_size too small. |
Raise in http or server block. Also check any CDN/WAF in front. |
X-Cache-Status is always MISS |
Upstream sends Set-Cookie or Cache-Control: private. |
Strip with proxy_ignore_headers Set-Cookie Cache-Control; only where safe; or proxy_hide_header Set-Cookie. |
auth_request returns 500 for every request |
Auth upstream cannot be reached, or internal missing. |
Test curl -H 'Host: internal.example.com' http://127.0.0.1/auth from the nginx host; check error log. |
| Real client IP in logs shows the L4 balancer's IP | set_real_ip_from not configured. |
set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on; |
| Streaming endpoint buffers until the response completes | proxy_buffering on default. |
Turn it off for that location. Add X-Accel-Buffering: no on the backend response for belt-and-braces. |
Nginx refuses to reload: "proxy_pass" cannot have URI part in location given by regular expression |
Regex location + URI in proxy_pass. |
Drop the URI; use rewrite to shape the request path before proxy_pass. |
auth_request + X-Accel-Redirect is two blocks from this page glued together. See Nginx Config for the core server-block boilerplate and Apache if you are migrating from mod_proxy.