#DevOps

Nginx Reverse Proxying in Practice

Nginx is the most common reverse proxy and gateway in deployments, yet many developers only know how to copy a configuration snippet. This guide covers location matching, proxy_pass, X-Forwarded-* headers, load balancing, HTTPS/TLS, worker/buffer/gzip tuning, and common WebSocket, large-upload, and streaming pitfalls.

Key Points

  • Topic Focus: DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

I have interviewed many candidates who claimed they "knew Nginx." Ask them to write a configuration that forwards /api/ to a backend and passes through the real client IP, and eight out of ten get it wrong. They either forget proxy_set_header or add an extra / to the end of proxy_pass, causing the backend route to return 404. An Nginx reverse proxy is one of the most common parts of a deployment, but there is an entire manual between "it runs" and "it is configured correctly." This article explains the most common reverse-proxy pitfalls in one place, with configurations written to production-ready standards.

1. What Does a Reverse Proxy Actually Solve?

Exposing an application directly on port 443 is one of the most naive ideas a beginner can have. Doing so immediately gives up the ability to hide the backend topology, a central point for TLS termination, load distribution during horizontal scaling, and an independent gateway layer for caching and rate limiting.

The core benefits of a reverse proxy, in order of importance, are:

  1. Hide the backend: The outside world sees only Nginx's IP address and port. Backend services can listen on an internal network, use arbitrary ports, run multiple instances, or even perform rolling restarts and releases without clients noticing.
  2. Provide a unified entry point and TLS termination: Install the certificate only on Nginx and let the backend use plain HTTP. Renewal, OCSP, HTTP/2, and compression are all handled at the gateway layer, so the backend does not need to care about them.
  3. Load balancing: Place multiple backends behind one domain, let Nginx distribute traffic according to a policy, and automatically remove failed instances.
  4. Caching and rate limiting: Cache popular responses in Nginx to cut backend load directly in half, and rate-limit traffic spikes at the gateway so they do not overwhelm the backend.

In short: Nginx is the gate between your application and the public Internet, not an optional "forwarder." It lets the backend focus on business logic instead of TLS, route distribution, and traffic shaping.

2. location Matching: Where Beginners Make the Most Mistakes

The first step in reverse proxying is deciding "which path goes to which backend." That is the role of location. Its matching rules do not simply select the first match from top to bottom; they follow a specific priority order. Eighty percent of "why didn't my location take effect?" problems come from misunderstanding that order.

The four matcher types, from highest to lowest priority, are:

text
=        Exact match (highest priority; stop on match)
^~       Prefix match (skip regex checks after a match)
~ / ~*   Regex match (case-sensitive / case-insensitive)
/xxx     Plain prefix match (lowest priority; longest prefix wins)

The actual selection order is: first check for an = match; if there is none, remember the longest ^~ prefix; then check regular expressions, using the first one that matches in declaration order; if no regular expression matches, use the longest previously recorded plain prefix. Here is a typical counterexample:

nginx
location /api { proxy_pass http://backend; }   # You expect it to handle /api/users
location ~ \.php$ { ... }                       # But the regex has higher priority

A request to /api/test.php is captured by the regex line. A regex always outranks a plain prefix, which is where beginners most often go wrong. If a prefix can solve the problem, do not write a regex merely for convenience. Prefixes use hash lookups, while regular expressions are scanned linearly one by one and also perform worse.

3. proxy_pass and Forwarded Headers: Give the Backend the Real IP

location decides where to forward a request, while proxy_pass decides how to forward it. This is where the most notorious pitfall appears: including or omitting the trailing / produces completely different behavior.

nginx
location /api/ {
    proxy_pass http://backend;     # No URI: forward unchanged  /api/users → /api/users
    proxy_pass http://backend/;    # With a URI: replace prefix /api/users → /users
    proxy_pass http://backend/v2/; #                         /api/users → /v2/users
}

The rule in one sentence: when proxy_pass includes a URI, even if it is only /, it replaces the prefix matched by location; without a URI, it forwards the path unchanged. Note that when location uses a regular expression, proxy_pass cannot include a URI and must use only a bare host:port.

The backend receives the real client IP through forwarded request headers. By default, Nginx does not tell the backend who the client is, so the backend sees only Nginx's internal IP. A production configuration must include these lines:

nginx
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;

X-Forwarded-For is a chain that appends one IP address at each proxy hop. $proxy_add_x_forwarded_for combines the XFF value already present in the request with the current $remote_addr, which is exactly the desired behavior. X-Forwarded-Proto tells the backend that "the original request used https," allowing it to generate correct https:// redirect URLs.

The backend must cooperate as well. Frameworks such as Express with trust proxy and Django with SECURE_PROXY_SSL_HEADER require an explicit declaration that "the preceding proxy is trusted"; otherwise, any client can forge these headers. The trust boundary must be clear along a reverse-proxy chain: Nginx is trusted, while the client is not.

4. Load Balancing: Multiple Backends with upstream

When one backend cannot handle the load, upstream groups multiple backends into one virtual service:

nginx
upstream backend {
    least_conn;                         # Scheduling policy: prefer the fewest connections
    server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.3:8080 weight=2;      # Weight: receives twice as many requests
    keepalive 32;                       # Persistent connection pool to the backend
}

You only need to remember three scheduling policies:

  • Round robin (default): Distributes requests in sequence. Use it when all backends have equivalent configurations;
  • ip_hash: Routes the same client IP to the same instance for session affinity. The cost is large-scale hash redistribution when nodes are added or removed. Modern applications should store sessions in Redis instead of relying on IP affinity;
  • least_conn: Prioritizes the backend with the fewest connections. It is more appropriate than round robin when backend processing capacity varies.

max_fails + fail_timeout provides passive health checks. If an instance fails max_fails consecutive times during fail_timeout, it is temporarily removed. Note that this is passive: Nginx does not actively probe the instance. Active health checks require the commercial edition or a third-party module; the open-source edition can only wait for failures.

keepalive 32 creates a persistent connection pool to the backend, avoiding a new TCP connection for every request. After enabling it, remember to add both proxy_http_version 1.1; and proxy_set_header Connection ""; inside location, or keepalive will not work.

5. HTTPS/TLS: Certificates, Redirects, and HTTP/2

TLS should be terminated at the gateway layer. A minimal production configuration looks like this:

nginx
server {
    listen 443 ssl;
    http2 on;                         # New syntax in nginx 1.25.1+, replacing the old listen 443 ssl http2
    server_name example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;   # Permanent HTTP → HTTPS redirect
}

A few important details: use fullchain.pem, which includes intermediate certificates, rather than cert.pem; otherwise, some clients fail because the chain is incomplete. Starting with version 1.25.1, HTTP/2 uses the http2 on; directive instead of being declared in listen. Automate certificate renewal with certbot renew plus cron. Do not wait for expiration and renew manually; that is a frequent source of operational incidents.

Using Brotli instead of gzip requires the ngx_brotli module and works only over HTTPS. Its compression ratio is 10%~20% better, but you must compile it yourself. For teams that are not especially sensitive to bandwidth, gzip is sufficient.

6. Performance Tuning: Workers, Buffers, gzip, and Static Caching

nginx
worker_processes auto;                 # Match the number of CPU cores
events { worker_connections 10240; }   # Maximum connections per worker

http {
    sendfile on;
    tcp_nopush on;
    keepalive_timeout 65;

    gzip on;
    gzip_min_length 1k;
    gzip_comp_level 5;                 # 4–6 is the sweet spot; 9 mostly just burns CPU
    gzip_types text/plain application/json application/javascript text/css;

    location ~* \.(js|css|png|jpg|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # Reverse-proxy buffers (defaults are small; large responses spill to temporary files)
    proxy_buffer_size       16k;
    proxy_buffers           8 16k;
    proxy_busy_buffers_size 32k;
}

The key to tuning is not to adjust settings blindly. Let worker_processes auto align Nginx with the CPU instead of entering a value manually. Increase worker_connections according to expected concurrency, but verify the system's ulimit -n, or Nginx will fail to start. The benefit of setting gzip_comp_level above 6 is negligible.

Reverse-proxy proxy_buffers are an often-overlooked bottleneck. Their default values are small, so a large JSON response from the backend may be written to a temporary file on disk, sharply increasing latency. Increase these values for APIs that proxy large response bodies.

Fingerprint-based static assets whose filenames contain hashes can use immutable, preventing the browser from ever returning to the origin. HTML without fingerprints should use a short cache lifetime or no caching.

7. Common Pitfalls: WebSockets, Large Files, and Streaming

Finally, four issues frequently cause production incidents:

1. WebSockets do not work. Nginx uses HTTP/1.0 to communicate with the backend by default and drops the Upgrade / Connection headers. Protocols that require an upgrade, such as WebSocket and gRPC, must pass them explicitly:

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

location /ws {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 3600s;         # Long-lived connection; do not let the default 60s terminate it
}

2. Large uploads return 413. Nginx defaults client_max_body_size to only 1m, so even moderately large uploads return 413. Set it explicitly for your use case: client_max_body_size 100m;. It can be placed at the http, server, or location level.

3. Streaming output gets swallowed. SSE from LLMs, token-by-token output, and streaming responses using Transfer-Encoding: chunked are fully buffered before being sent when proxy_buffering is enabled by default. The user experience collapses into "wait 10 seconds, then see everything at once." Buffering must be disabled for streaming endpoints:

nginx
location /stream {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
}

4. The backend sees Nginx's IP address. This is the forwarded-header configuration problem described in Section 3. When every production log entry contains the same internal IP, risk controls, rate limiting, and auditing all become inaccurate. This is the most basic and most common missing configuration.

Summary

  1. The location priority is = > ^~ > regex > plain prefix. A regex always outranks a plain prefix, so use prefixes instead of regexes whenever possible.
  2. A trailing / on proxy_pass completely changes its behavior: including a URI replaces the location prefix, while omitting it forwards the path unchanged. This is the most subtle source of routing 404s.
  3. Forwarded request headers (X-Real-IP / X-Forwarded-For / X-Forwarded-Proto / Host) are the conscience of a reverse proxy. Without them, the backend is blind; the backend must also explicitly define the trusted proxy boundary.
  4. WebSockets, large files, and streaming are the three most frequent sources of incidents: the Upgrade headers, client_max_body_size, and proxy_buffering off address persistent connections, uploads, and SSE respectively.

The golden rule of reverse-proxy configuration is simple: always assume your configuration is wrong, then verify every item with curl -v and backend logs. Nginx reporting no errors does not mean it did the right thing.

Related

Related Content