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:
| Metric | How to get it | Use |
|---|---|---|
| Active upstream conns | ss -tn state established ‘( dport = :9000 )’ | wc -l | Peak concurrency split across workers |
| Worker count | worker_processes auto | Divide concurrency by this |
| Requests/sec | access log rate | Feeds 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 | headIf 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_requeststoo high lets a single connection live long enough to skew load balancing; 100–1000 is typical.keepalive_timeoutlonger 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 reusedVerifying 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 trendA 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
| Symptom | Cause | Fix |
|---|---|---|
| upstream_connect_time never hits 0 | proxy_http_version not set to 1.1 | add proxy_http_version 1.1 |
| Reuse works, then sporadic 502s | keepalive_timeout > backend idle timeout | lower it below the backend’s |
| Pool seems exhausted under load | keepalive too small for peak concurrency | raise it; observe with ss |
| No effect at all | Connection: close forwarded | proxy_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
You must be logged in to post a comment.