...

Optimally Configuring NGINX Upstream Keepalive for Maximum Performance as a Reverse Proxy

I configure NGINX Upstream Keepalive so that the reverse proxy establishes fewer connections, delivers lower latency, and reliably handles traffic spikes. To do this, I adjust Pool Size, time limits, and headers in a targeted manner so that connections are reused and the data path remains streamlined.

Key points

  • HTTP/1.1 Enforce and clean up connection headers
  • keepalive Size correctly for each worker
  • Timeouts Adjust to backend values
  • Requests/Connection reduce and recycle
  • Monitoring for connection speed and latency

Why Upstream Keepalive Drastically Reduces Connection Overhead

Without reuse, NGINX opens a new backend connection for each request, which requires extra handshakes, more CPU cycles, and additional kernel resources; this is exactly where Keepalive I have NGINX cache already established, currently idle sockets and reuse them for subsequent requests, which measurably reduces connection times. This lowers the connection rate per second, reduces backlog spikes, and minimizes context switches in the operating system. I save a noticeable amount of time through reused sessions, especially with TLS connections to the backend. This keeps the response chain consistent even at high throughput reliable and responds smoothly.

Basic Principle and the keepalive Directive in the Upstream

The Directive keepalive In the upstream block, this limits the number of idle backend connections cached per worker. This limit does not apply globally, but strictly per worker process, which is why I always keep an eye on the number of workers. When the pool is full, NGINX closes the connection that has been idle the longest first to make room for new sockets. For reuse, the proxy side requires HTTP/1.1 and a neutralized Connection header. Without these prerequisites, the pool remains empty, even though I set „keepalive“ in the upstream, which many admins at first surprised.

upstream backend_pool {
    server 192.168.1.10:8080;
    server 192.168.1.11:8080;
    server 192.168.1.12:8080;

    keepalive 32; # idle connections per worker
    keepalive_requests 1000;   # recycling after N requests
    keepalive_timeout 60s;     # idle lifetime
}

server {
    listen 80;
    location / {
 proxy_pass http://backend_pool;
 proxy_http_version 1.1;
 proxy_set_header Connection "";
    }
}

Mandatory Directives in the Location Block: HTTP/1.1 and Header Control

I force NGINX to use HTTP/1.1 in the proxy path because Keepalive doesn't work properly with HTTP/1.0, causing connections to terminate unnecessarily; the directive proxy_http_version 1.1 is therefore required. In addition, I remove the `Connection` header for regular requests so that the backend does not receive a „close“ instruction. For upgrades such as WebSockets, I use `map` to specifically set `Connection: Upgrade` without affecting normal reuse. This keeps the connection policy consistent and decoupled from client headers. It is precisely this small change that prevents many elusive error patterns.

location / {
    proxy_pass http://backend_pool;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    "" "";
}

Fine-Tuning: Selecting the Correct Values for `keepalive_requests` and `keepalive_timeout`

Using two adjustment screws, I control the lifespan and renewal of the connections so that the pool stays fresh and no orphaned sockets cause problems; these are keepalive_requests and keepalive_timeout. After N requests, NGINX intentionally closes the connection and reestablishes it as needed, which mitigates network aging effects. I tend to set the idle timeout on the shorter side, usually between 30 and 120 seconds, so that backends don’t disconnect prematurely. Coordination is key: The NGINX value should never exceed the app server’s timeout, otherwise connection resets will accumulate. If you’d like to delve deeper into the background, you’ll find practical tips in the article Keepalive Timeout, which explains typical values and interactions.

To help you get your bearings quickly, I've listed common default values and their respective purposes in a clear, easy-to-read table Table. These guidelines serve as a starting point and often end up slightly higher or lower after monitoring. A timeframe that’s too short causes unnecessary re-establishments, while one that’s too long keeps old connections open. I use the number of requests per connection to protect against outliers without emptying the pool. With these key metrics, I can very quickly achieve a system that works Defaults.

Parameters Purpose reference value Tuning Note
keepalive Size of the idle pool per worker 32-64 Align with the simultaneous load per worker
keepalive_requests Maximum requests per connection 500–1000 Set it a little higher for long streams
keepalive_timeout Maximum idle time per connection 60s Shorter or Same Backend Idle Timeout

Determine Pool Size Based on Concurrent Connections

I don't choose the pool size based on requests per second, but rather on Concurrency per worker. First, I determine the average and maximum number of concurrent backend requests. Then I divide these numbers by the number of NGINX workers and round up. For 200 concurrent requests with four workers, I arrive at about 50 per worker, which makes keepalive 64 a suitable starting value. This way, I keep sockets available without keeping an unnecessarily large number of open Connections to bind.

Leverage the Unique Features of Newer NGINX Versions

Current versions often allow reuse by default, but set fairly conservative limits; I'll include the values anyway explicitly This ensures reproducibility, makes tuning easier, and prevents surprises after an update. Using the „local“ parameter, I can optionally restrict reuse to a single location if security profiles or header policies differ. This keeps the separation clean without losing the benefits of global reuse. By using clear values, I document my intentions and save time later Analysis time.

Monitoring and Metrics: Is the Configuration Actually Working?

First, I check the number of new backend connections per second; a significant drop indicates that the measures are taking effect Reuse. Then I monitor `upstream_connect_time`, which is close to zero when there are hits in the pool. Errors in the logs—specifically connection resets—indicate timeouts that are behind the backend values. In addition, I correlate backend CPU usage and latencies with the percentage of reused connections. For a deeper understanding of Connection Reuse Examples that illustrate the effects under various load patterns are helpful.

Quickly Eliminate Common Sources of Error

If HTTP/1.1 isn't supported by the backend, connections remain short-lived, no matter how high I keepalive set. If the client sends „Connection: close“ and I pass the header through unfiltered, the backend closes each connection immediately after the response. If idle timeouts don't match, the app side terminates the connection first, and NGINX triggers a reset on the next request. An oversized pool keeps too many sockets open and wastes memory and ports. I check these four points during every analysis as First, because they explain 90 % of all problems.

Practical Example: Reference Configuration for High Throughput

With just a few instructions, I can get a heavily loaded proxy up and running quickly and reliably and ensure clean header forwarding; the following pattern has proven effective and is easy to customize. I set keepalive to 64, limit requests per connection to 1,000, and set the idle time to 60 seconds. In addition, I correctly pass along host and forwarded information so that backends can apply logic and rate limiting. This combination reduces CPU load, shortens response times, and handles load spikes more smoothly. This is exactly how I achieve a highly predictable Performance.

upstream app_backend {
    server 10.0.1.10:3000 max_fails=2 fail_timeout=30s;
    server 10.0.1.11:3000 max_fails=2 fail_timeout=30s;

    keepalive 64;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

server {
    listen 80;
    server_name example.com;

    location / {
 proxy_pass http://app_backend;
 proxy_http_version 1.1;
 proxy_set_header Connection "";
 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;
    }
}

Hosting Environments and Operational Considerations That Really Matter

I often place NGINX in front of PHP-FPM, Node.js, or Java services and make sure that network latency remains low and backend timeouts are consistent; this provides Plannability. A robust kernel network configuration with appropriate socket limits prevents a large number of open connections from interfering with one another. Even CPU allocation and fast storage paths help the backends maintain short response times. I also ensure that configurations are versioned so that changes remain traceable. With this discipline, the system remains stable during traffic spikes responsive.

Best practices for ongoing operations

I start with a keepalive of 32–64, 500–1,000 requests per connection, and a 60-second idle time, then systematically measure and adjust the values; this leads to quick achievements. I monitor every change with metrics on connection rates, latency, and error patterns until the curves stabilize. I base the pool size on concurrent requests, not on raw throughput per second. Timeouts should never be longer than their counterparts in the backend stack; otherwise, sporadic resets may occur. If you want to fine-tune performance further, you’ll find tips on fine-tuning at Optimize Keepalive Requests, which makes recycling quite manageable.

Synchronizing Proxy Timeouts and TCP Keepalive

In addition to the keepalive parameters themselves, I fine-tune the transport timeouts. The trio of proxy_connect_timeout, proxy_send_timeout and proxy_read_timeout determines how patient NGINX is when establishing connections, sending, and receiving data. I never set these values higher than their counterparts in the backend; instead, I set them slightly lower so that errors become apparent early on and don't escalate on the app side. In addition, I enable proxy_socket_keepalive, so that the operating system sends periodic "alive" signals over inactive sockets and detects half-open connections. This prevents dead connections from remaining in the pool and causing latency spikes during the next request.

server {
    listen 80;

    location / {
 proxy_pass http://backend_pool;

 proxy_connect_timeout 3s;   # Fail quickly if connection cannot be established
 proxy_send_timeout    30s;  # Write to the backend
 proxy_read_timeout    30s;  # Responses from the backend
 proxy_socket_keepalive on;  # Enable OS TCP keep-alive
    }
}

For long-running streams (e.g., SSE or WebSockets), I increase only the read timeout, while the connect timeout remains unchanged. This allows me to respond quickly to faulty targets while letting legitimate, long responses run uninterrupted.

Resource Planning: worker_connections, FDs, and Ephemeral Ports

A clean keepalive pool is useless if file descriptor limits or port ranges are exhausted. Therefore, I plan to worker_connections and worker_rlimit_nofile with a margin. As a rough estimate, I calculate: Open FDs ≈ (concurrent client connections + concurrent backend connections + pooled idle sockets) per worker. If I use multiple upstreams with pools, the requirement multiplies. I also pay close attention to the system’s ephemeral port range, since NGINX acts as a TCP client toward the backend and accumulates TIME_WAIT states.

worker_processes auto;
worker_rlimit_nofile 131072;

events {
    worker_connections 8192;
}
# Linux Examples (sysctl):
net.core.somaxconn = 4096
net.ipv4.ip_local_port_range = 10240 65535
net.ipv4.tcp_fin_timeout = 15

I'm taking a conservative approach: instead of aggressively clearing the TIME_WAIT state, I'm reducing the connection rate via keepalives. This way, kernel parameters remain uncritical and the behavior remains predictable.

Upstream Zones, Load Balancing Strategy, and DNS Rotation

When there are multiple workers, I share the balancer state via a zone, so that failures and loads remain consistent. Keepalive sockets are still assigned per worker, but the distribution becomes more even. For dynamic backends that move via DNS, I set „resolve“ in the server lines and define a resolver. Important: When IPs rotate, the pool does not immediately recycle all old sockets; therefore, I believe keepalive_requests and realistic time limits so that the renewal takes effect promptly.

upstream backend_pool {
    zone backend_zone 128k;  # shares balancer state
    least_conn; # fair distribution for long requests

 server app-1.internal:8080 resolve;
    server app-2.internal:8080 resolve;

 keepalive 64;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

resolver 10.0.0.2 valid=30s;
resolver_timeout 5s;

proxy_next_upstream error timeout http_502 http_504;
proxy_next_upstream_tries 2;  #: a few, targeted retries

For sessions that are bound to a specific backend node (e.g., sticky state), I combine reuse with ip_hash or an external session mechanism. This prevents connection pooling from disrupting session consistency.

TLS to the Backend: SNI, Session Reuse, and Ciphers

The more TLS is used in the backend path, the more valuable Keepalive becomes. I enable SNI, specify the expected name, and ensure that the TLS session is reused. This reduces handshake overhead and smooths out latency spikes. I choose cipher suites and protocols selectively, without excluding older backends. For certificate validation (optional), the trust chain must be complete; otherwise, connections will occasionally drop.

upstream https_backend {
    server backend.example.local:443;
    keepalive 32;
}

server {
    listen 443 ssl;

 location / {
 proxy_pass https://https_backend;
 proxy_http_version 1.1;
 proxy_set_header Connection "";

        proxy_ssl_server_name on;
 proxy_ssl_name backend.example.local;
 proxy_ssl_session_reuse on;
 proxy_ssl_protocols TLSv1.2 TLSv1.3;
        proxy_ssl_ciphers HIGH:!aNULL:!MD5;
 # optional: proxy_ssl_verify on;
 # optional: proxy_ssl_trusted_certificate /etc/nginx/ca.pem;
    }
}

If I manage the backend myself, I enable session tickets or caches there and use metrics to check whether resumption rates are increasing. Combined with Keepalive, this allows me to maintain consistently low connect and handshake times.

Special Cases: gRPC, WebSockets, and Connection-Bound Authentication

At gRPC NGINX operates upstream over HTTP/2. Here, a small number of long-lived connections with many streams often yield the best results; the pool remains small but stable. For WebSockets I set long read timeouts and keep the header logic from the map solution so that upgrade connections aren't accidentally closed. NTLM or other connection-bound authentication methods require connection pinning; I separate such paths into their own locations and reduce pooling or reuse there to ensure that security handshakes are not mixed up between clients.

# gRPC Example
location /grpc.Service/ {
    grpc_pass grpc://backend_pool;
    grpc_read_timeout 300s;  Allow #-length streams
}

It is crucial to establish a consistent connection policy for each path and to use keepalives extensively only where it is semantically non-critical.

Measurability in Practice: Access Logs with Upstream Timings

I'm adding upstream metrics to the access log. This lets me see at a glance whether a response came from a pooled socket (very short connect time) and how often backend errors occur. I also log the connection number and the number of requests made via the current client connection to identify correlations.

log_format upstream_timing '$remote_addr - $host "$request" '
 'up=$upstream_addr '
 'sc=$status usc=$upstream_status '
                           'cc=$connection cr=$connection_requests '
 'tc=$upstream_connect_time '
 'th=$upstream_header_time '
 'tr=$upstream_response_time';

access_log /var/log/nginx/access_upstream.log upstream_timing;

I also use status endpoints and OS socket statistics. A healthy state is indicated by: a decreasing connection rate to the backend, shorter `upstream_connect_time`, stable response times, and very few connection resets. Deviations almost always indicate mismatched timeouts or pools that are too small or too large.

Rollout Strategy and Low-Risk Tuning

I take an iterative approach: small steps, measure, adjust. First, I enable keepalives at a moderate level, then adjust timeouts and the number of requests per connection. I apply changes by reloading the page without disconnecting active connections. This keeps the risk low and makes it easy to pinpoint the effects.

# Validate changes and load them without downtime
nginx -t && nginx -s reload

When I'm running multiple upstreams, I tune them one after another, starting with the most critical path. I set a monitoring window for each stage so that patterns in the metrics become clear. Only then do I scale the values up or down.

A Brief Summary of Your Reverse Proxy

I use HTTP/1.1, clear the Connection header, and choose the pool size based on concurrent requests, not RPS; this supports the Performance. I use `keepalive_requests` and `keepalive_timeout` to keep connections alive and avoid surprises caused by stale sockets. Monitoring shows whether `upstream_connect_time` is approaching zero and whether the connection rate to the backend is decreasing. When errors occur, I first check the protocol version, header passing, timeouts, and pool size. This keeps your NGINX proxy running smoothly under heavy load responsive and predictable.

Current articles