Nginx Reverse Proxy Patterns

The reverse-proxy patterns you need in practice: upstream pools, the trailing-slash gotcha, WebSockets, proxy_cache with revalidation, auth_request subrequests, and body/header limits. Copy-paste configs; no hand-waving.

The trailing slash in 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

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:

  1. If proxy_pass ends with a hostname only (no path, not even /), nginx sends the original request URI to the upstream unchanged.
  2. If proxy_pass includes any path — including a bare / — nginx replaces the matched location prefix 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/;
}
Mixing regex 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:

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.

If your WebSockets disconnect after exactly 60 seconds you are hitting the default 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:

  1. rm the cache directory — crude but deterministic.
  2. Rename the keys_zone (e.g. app_cache_v2) and reload; the old zone's disk files become orphans and inactive= 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:

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

SymptomLikely causeFix
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.
Reusable. These patterns compose — a protected download with 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.