Tuning Nginx for High-Concurrency VPS Workloads: worker_connections and Keepalive Sizing

Nginx is famous for handling enormous concurrency on modest hardware, but the defaults are conservative and the tuning advice online is mostly copy-pasted folklore. The real limits are arithmetic, not mystique: connections per worker, worker count, file descriptor ceilings, and how long idle keepalive connections hold resources. This article derives the correct values for your specific VPS from measured capacity, then shows how to verify the tuning actually lifted throughput. It pairs naturally with broader VPS performance work; for the related question of process pool sizing, the blog also covers Nginx worker_processes and worker_connections sizing.

The Arithmetic Behind worker_connections

Total concurrent connections a server can hold is roughly:

worker_processes × worker_connections ÷ 2

The division by two matters: when Nginx proxies a request, it uses one connection to the client and one to the upstream backend. A reverse-proxy config therefore consumes two connection slots per active request. If you set worker_connections 1024 on a 2-vCPU box with worker_processes 2, your real ceiling is about 1,024 simultaneous proxied requests — not 2,048. Getting this wrong is the most common source of mysterious capacity limits.

Choosing worker_processes and worker_connections

Set worker_processes auto; to match vCPUs — check the count first, since a “2 GB plan” might still be single-vCPU:

nproc
nginx -V 2>&1 | tr ' ' '\n' | grep with-

Then size worker_connections generously, because the cost is only the memory of the connection structures, not per-connection processes. A static-file server and a reverse proxy need different values:

worker_processes auto;
events {
    worker_connections 4096;
    multi_accept on;
    use epoll;
}

multi_accept on lets a worker accept all pending connections in one wakeup, reducing wakeups under bursty load. use epoll is the correct event method on Linux — leave it explicit so it does not silently differ from a vendor build’s default.

Raising File Descriptor Limits

None of the above works if the process cannot open enough file descriptors. Every connection is a descriptor, and the default soft limit of 1024 will cap you far below worker_connections. Raise both the system limit and the service limit:

# /etc/security/limits.conf
nginx soft nofile 65536
nginx hard nofile 65536

# /etc/systemd/system/nginx.service.d/override.conf
[Service]
LimitNOFILE=65536

Reload systemd and restart Nginx, then confirm from inside the running process:

sudo systemctl daemon-reload && sudo systemctl restart nginx
cat /proc/$(cat /run/nginx.pid)/limits | grep 'open files'

The verified number must match your target. The general topic of descriptor ceilings on busy hosts is covered in raising file descriptor and process limits on a VPS.

Keepalive Sizing: The Hidden Connection Consumer

Idle keepalive connections occupy a worker connection slot for the whole timeout. The default keepalive_timeout 75s means a client that fetched one asset and vanished still holds a slot for over a minute. Under high concurrency this is a large fraction of your capacity. Tune both sides:

keepalive_timeout 15s;
keepalive_requests 1000;

upstream backend {
    server 127.0.0.1:8080;
    keepalive 32;
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

Two distinct settings are at play. The client-facing keepalive_timeout (15s) frees slots faster from browsers. The upstream keepalive 32 pool reuses backend connections instead of opening a new one per request, which matters enormously for a reverse-proxy setup. The mechanics of sizing that upstream pool are detailed in Nginx upstream keepalive connection pooling.

Verifying the Tuning Lifted Capacity

Do not stop at configuration — measure the difference. Enable the stub status module and watch active connections under load:

location /nginx_status {
    stub_status;
    allow 127.0.0.1;
    deny all;
}

curl -s http://127.0.0.1/nginx_status

Then drive load with a concurrency-capable tool and compare before/after. wrk at a fixed connection count shows whether the tuned config serves more requests per second at the same latency:

wrk -t4 -c400 -d30s http://127.0.0.1/

A properly tuned server shows higher throughput and lower tail latency at the same -c400 connection count. If throughput is flat while compression and caching are already off, the bottleneck is elsewhere — CPU, upstream, or disk — and no amount of Nginx tuning will help.

Common Mistakes to Avoid

Pushing worker_connections to absurd values (100000) wastes memory and masks the real limit. Disabling keepalive entirely to “save connections” usually makes latency worse by forcing a TCP and TLS handshake on every request. And raising every setting at once means you cannot tell which change helped — adjust one variable, re-benchmark, then move on.

Timeouts and Buffers That Interact With Concurrency

Beyond connection counts, three directives decide how long a slot stays occupied and how much memory each request holds. They are frequently left at defaults that suit a dedicated server, not a small VPS:

client_body_timeout 12s;
client_header_timeout 12s;
send_timeout 12s;

client_body_buffer_size 16k;
client_header_buffer_size 1k;
large_client_header_buffers 2 1k;

Shorter timeouts evict slow or abandoned clients faster, returning their connection slots to the pool — a direct concurrency win. The buffer sizes are a memory-versus-robustness trade: smaller buffers let more simultaneous requests fit in a fixed RAM budget, but headers larger than one buffer spill to disk, adding I/O latency. On a memory-constrained host, the right answer is usually modest buffers and a higher accepted-headers limit rather than one large buffer per connection.

Compute Your RAM Budget Before Raising Limits

Every active connection carries kernel socket buffers plus Nginx’s own per-request structures. Raising worker_connections without checking available RAM is how a tuning session turns into an OOM event. Estimate the ceiling from free memory first:

free -m
cat /proc/meminfo | grep -E 'MemAvailable|Committed_AS'

If peak concurrency would consume more than the available headroom, either cap worker_connections below that point or lean harder on fast-cache and file-serving offload so fewer requests reach the backend. The general principle of reading memory pressure correctly is covered in reading load average versus CPU saturation — the same false-confidence trap applies to connection counts that look healthy while the machine is already swapping.

The Takeaway

High-concurrency Nginx tuning is arithmetic, not magic. Divide by two for proxy connections, match worker_connections to a raised file descriptor limit, shorten client keepalive while pooling upstream keepalive, and verify every change with stub_status and a load generator. Tuned this way, a modest VPS handles concurrency far beyond its apparent size — and you will know the real ceiling instead of guessing at it.

Leave a Reply