Nginx Upstream Keepalive on a VPS: Sizing Connection Pools With Measured Evidence

Without keepalive, every proxied request makes Nginx open a fresh TCP connection to your app server, run the handshake, serve, and tear it down. Under load this shows up as TIME_WAIT accumulation, ephemeral port pressure, and latency you cannot explain from app timings alone. Upstream keepalive fixes it — but the defaults and the copy-pasted keepalive 32 line are frequently wrong for a small VPS. This article sizes the pool from measurement.

The two-zone configuration

Keepalive needs a directive in the upstream block and a protocol version in proxy_http_version. Miss either and you get connection reuse silently disabled.

upstream app_backend {
    server 127.0.0.1:9000 max_fails=3 fail_timeout=10s;
    keepalive 16;                 # idle keepalive conns cached per worker
    keepalive_requests 1000;      # requests per conn before recycle
    keepalive_timeout 60s;        # idle timeout of cached conns
}

server {
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;   # REQUIRED for keepalive
        proxy_set_header Connection "";
        proxy_next_upstream error timeout http_502;
    }
}

proxy_set_header Connection "" clears the Connection: close that Nginx otherwise forwards, which would defeat reuse.

Sizing keepalive from concurrency, not vibes

The pool is per worker process. A reasonable starting point is the number of upstream connections a single worker needs concurrently, plus headroom:

MetricHow to get itUse
Active upstream connsss -tn state established ‘( dport = :9000 )’ | wc -lPeak concurrency split across workers
Worker countworker_processes autoDivide concurrency by this
Requests/secaccess log rateFeeds keepalive_requests
# observe real upstream connection churn
ss -tn state established '( dport = :9000 )' | wc -l
ss -s | grep -i timewait

# count new backend connections per second
awk '{print $4}' /var/log/nginx/access.log | \
  cut -d: -f1 | uniq -c | sort -rn | head

If TIME_WAIT on the upstream side drops to near zero after enabling keepalive, reuse is working. If it does not, verify proxy_http_version 1.1 and the cleared Connection header before touching pool size. The same principle — verify the mechanism, then tune the number — applies across the

Memory and correctness on a small box

  • Each cached upstream connection holds a socket plus kernel buffers; on a 1 GB VPS keep pools modest (8–32, not 256).
  • keepalive_requests too high lets a single connection live long enough to skew load balancing; 100–1000 is typical.
  • keepalive_timeout longer than your upstream’s idle timeout causes reuse of connections the backend has already closed — a classic source of sporadic 502s.
  • Downstream and upstream keepalive are independent; tuning client keepalive does nothing for backend churn.

Confirm the effect with request-level timing. Backend connect time should vanish from upstream_connect_time:

log_format timed '$request_time $upstream_connect_time '
                  '$upstream_header_time $upstream_response_time';
# upstream_connect_time ~0.000 => a cached connection was reused

Verifying reuse is real

nginx -T | grep -A6 'upstream app_backend'   # effective config
ss -tn '( dport = :9000 )' | tail -n +2 | wc -l
ss -s | grep -A2 TCP:                          # TIME_WAIT trend

A healthy result: established upstream connections stay roughly flat during a load test while request throughput rises, and TIME_WAIT no longer grows without bound. Once keepalive is in place, the next bottleneck is usually the reverse-proxy layer itself — the keepalive, buffering and timeout directives in the Nginx reverse proxy guide cover the next stage.

Pair this with a database and PHP-FPM layer that can actually use the reduced connection overhead; the low-memory tuning approach keeps the app server from becoming the new ceiling.

Common ways keepalive silently stops working

SymptomCauseFix
upstream_connect_time never hits 0proxy_http_version not set to 1.1add proxy_http_version 1.1
Reuse works, then sporadic 502skeepalive_timeout > backend idle timeoutlower it below the backend’s
Pool seems exhausted under loadkeepalive too small for peak concurrencyraise it; observe with ss
No effect at allConnection: close forwardedproxy_set_header Connection “”

The second row is the one that wastes the most debugging time. If your application server closes idle connections after 30 seconds and Nginx caches them for 60, Nginx will hand a request to a socket the backend has already torn down. The upstream reports a reset, and Nginx returns 502. Set keepalive_timeout a few seconds below the backend’s idle timeout, not above it.

Load-testing the change safely

Do not tune pool size against real traffic. Reproduce a representative load against the backend over loopback or a private network so you isolate the proxy behaviour:

# simple concurrency sweep with ab or wrk
wrk -t4 -c100 -d30s http://127.0.0.1/ > baseline_keepalive_off.txt
# enable keepalive, restart nginx, repeat
wrk -t4 -c100 -d30s http://127.0.0.1/ > keepalive_on.txt
# compare: requests/sec, latency percentiles, TIME_WAIT count
diff <(grep -E 'Requests/sec|Latency' baseline_keepalive_off.txt) \
     <(grep -E 'Requests/sec|Latency' keepalive_on.txt)

Watch three numbers across the sweep: requests/sec should rise or stay flat, p99 latency should drop, and established upstream connections should stabilise instead of spiking with concurrency. If p99 does not improve, you are probably not CPU-bound at the proxy and the bottleneck lies downstream — reach for the app profiling workflow rather than adding more pool.

  • Change one directive per test run; otherwise you cannot attribute the result.
  • Reset counters (ss -s) or restart between runs so TIME_WAIT from a prior run does not confuse the trend.
  • Keep the payload realistic — a static 2-byte response exercises connection handling, not the app.

Once the pool is right, the pattern generalises: connection reuse is a systems property you verify with socket state, not a magic constant you paste. Apply the same discipline to downstream keepalive between your app server and database.

Leave a Reply