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
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:
- 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.
- 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.
- Load balancing: Place multiple backends behind one domain, let Nginx distribute traffic according to a policy, and automatically remove failed instances.
- 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:
= 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:
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.
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:
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:
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:
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
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:
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:
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
- The
locationpriority is=>^~> regex > plain prefix. A regex always outranks a plain prefix, so use prefixes instead of regexes whenever possible. - A trailing
/onproxy_passcompletely 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. - 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.
- WebSockets, large files, and streaming are the three most frequent sources of incidents: the Upgrade headers,
client_max_body_size, andproxy_buffering offaddress 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
Article
Considerations for Deploying a Next.js Community Project with Docker
Technical trends, engineering practices, and an independent developer perspective on DevOps and Next.js.
Article
How Can OpenTelemetry Help You Observe AI Applications?
Technical trends, engineering practices, and an independent developer's perspective on DevOps and AI.
Article
Long-Running Tool Calls: The Terminal-State Protocol for Operation, Poll, and Cancel
After the initial request times out, the tool may still be running server-side; issuing a cancel does not mean the side effects have stopped. This article establishes an operation identity, polling watermark, cancellation race handling, and a single terminal-state acceptance point for a single long-running tool call.
Article
Agent Memory Erasure Propagation: From User Forgetting Request to Verifiable Purge
Deleting one row in the primary table won't clear vectors, summaries, caches, or restored backups. This article uses a deletion state machine, generational tombstones, derived lineage, and end-to-end canary verification to truly forget.