Nginx Performance Tuning on a VPS
Tune Nginx for production traffic on a Linux VPS. Covers worker processes, compression, caching, HTTP/2, TLS optimization, kernel sysctl, and benchmarking with wrk.
A default Nginx install handles moderate traffic without issue. But defaults are conservative. On a 4-vCPU VPS with 8 GB RAM, you can serve many more requests per second by tuning worker processes, compression, caching, and kernel parameters. This guide walks through every layer, with benchmarks to prove each change matters.
We assume Nginx is already installed and serving traffic. If not, start with Nginx Administration on a VPS.
All examples target Nginx mainline on Debian 12 or Ubuntu 24.04. The config file structure is covered in Nginx Config File Structure Explained.
How do you establish a performance baseline for Nginx?
Before changing anything, measure current performance with wrk. This gives you a baseline to compare against after tuning. Without numbers, you are guessing.
Install wrk on a separate machine (your local workstation or another VPS). Never benchmark from the same server you are testing. The benchmarking tool and the web server compete for CPU, and results become meaningless.
apt install wrk
Run a 30-second test with 4 threads and 200 connections against a static page:
wrk -t4 -c200 -d30s https://your-server.example.com/
Running 30s test @ https://your-server.example.com/
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 12.34ms 5.67ms 89.12ms 78.45%
Req/Sec 4.12k 312.45 5.23k 72.50%
493440 requests in 30.01s, 1.92GB read
Requests/sec: 16442.18
Transfer/sec: 65.52MB
Record four numbers: requests/sec, average latency, max latency, and transfer/sec. These are your baseline.
How many worker processes and connections should Nginx use?
Set worker_processes auto to spawn one worker per CPU core. On a 4-vCPU VPS, this means 4 workers. Each worker is single-threaded and handles connections independently using epoll. One worker per core avoids context switching overhead.
The formula for maximum concurrent connections:
max connections = worker_processes x worker_connections
| vCPUs | worker_processes | worker_connections | Max connections |
|---|---|---|---|
| 1 | 1 | 2048 | 2,048 |
| 2 | 2 | 2048 | 4,096 |
| 4 | 4 | 2048 | 8,192 |
| 8 | 8 | 2048 | 16,384 |
Each connection consumes a file descriptor. Set worker_rlimit_nofile higher than worker_connections to avoid hitting the OS limit. A safe value is worker_connections * 2, which accounts for upstream connections when proxying.
Edit /etc/nginx/nginx.conf:
worker_processes auto;
worker_cpu_affinity auto;
worker_rlimit_nofile 8192;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
worker_cpu_affinity auto pins each worker to a CPU core, reducing cache misses from process migration. multi_accept on lets a worker accept all pending connections at once instead of one at a time. use epoll is the default on Linux but worth being explicit.
The accept_mutex directive defaults to off since Nginx 1.11.3 because Linux kernels 4.5+ support EPOLLEXCLUSIVE, which distributes connections across workers without a mutex. Leave it off.
nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
systemctl reload nginx
Which TCP directives improve Nginx throughput?
Three directives work together to optimize how Nginx sends data over TCP. sendfile bypasses the userspace buffer by copying data directly between file descriptors in the kernel. tcp_nopush batches response headers and the beginning of a file into a single TCP packet. tcp_nodelay disables Nagle's algorithm so small packets (like the end of a response) are sent immediately.
Add these to the http block in /etc/nginx/nginx.conf:
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
#...existing directives...
}
sendfile matters most for static file serving. Without it, Nginx reads the file into a buffer, then writes the buffer to the socket. Two copies. With sendfile, the kernel does a zero-copy transfer. On a busy static file server, this alone reduces CPU usage noticeably.
tcp_nopush and tcp_nodelay are not contradictory. Nginx applies tcp_nopush while building the response, then switches to tcp_nodelay for the final packet. The result: fewer packets overall, with no delay on the last one.
How do you tune keepalive connections for Nginx?
Keepalive connections let a client reuse a TCP connection for multiple HTTP requests. This avoids the overhead of TCP handshakes and TLS negotiation on every request. A single page load can trigger 20-50 subrequests for CSS, JS, images, and fonts.
http {
keepalive_timeout 65;
keepalive_requests 1000;
#...existing directives...
}
keepalive_timeout 65 closes idle connections after 65 seconds. Too high wastes file descriptors on idle clients. Too low forces reconnections. 65 seconds is a reasonable default for most workloads.
keepalive_requests 1000 allows up to 1,000 requests per connection before Nginx closes it. The default is 1000 since Nginx 1.19.10. If you are behind a load balancer sending many requests per connection, increase this to 10000.
Upstream keepalive
If Nginx proxies to a backend (Node.js, Python, Go), upstream keepalive connections prevent Nginx from opening a new TCP connection to the backend on every request. This is where most proxy setups waste time.
upstream backend {
server 127.0.0.1:3000;
keepalive 64;
}
server {
location / {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
keepalive 64 keeps 64 idle connections to the backend open per worker. proxy_http_version 1.1 is required because keepalive is an HTTP/1.1 feature. The empty Connection header clears the client's Connection: close header so Nginx does not forward it upstream.
For more on proxy configuration, see How to Configure Nginx as a Reverse Proxy.
How should you size Nginx proxy buffers?
When Nginx proxies a request, it buffers the backend's response. If the buffer is too small, Nginx writes the response to a temporary file on disk. Disk I/O is orders of magnitude slower than RAM.
http {
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
client_body_buffer_size 16k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
#...existing directives...
}
proxy_buffer_size 16k handles the response headers from the backend. Most headers fit in 4k-8k, but applications that set many cookies or custom headers need more. 16k is safe without being wasteful.
proxy_buffers 4 32k allocates 4 buffers of 32k each (128k total per connection) for the response body. Size these based on your typical response size. API responses under 100k fit comfortably. If you serve large payloads, increase the buffer count rather than the buffer size.
proxy_busy_buffers_size 64k controls how much buffered data Nginx can send to the client while still reading from the backend. It should not exceed the total of proxy_buffers.
Watch for this in your error log:
an upstream response is buffered to a temporary file
If you see it frequently, increase proxy_buffers. Check the log:
journalctl -u nginx --no-pager | grep "temporary file"
How do you configure static file caching in Nginx?
Static file caching tells browsers to store assets locally. This eliminates repeat requests entirely. For assets with hashed filenames (like app.a1b2c3.js), set aggressive expiration. For HTML, keep it short.
server {
location ~* \.(css|js|woff2|woff|ttf|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(jpg|jpeg|png|gif|ico|webp|avif)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
}
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, no-cache";
}
}
immutable tells the browser not to revalidate the asset at all. Only use this for fingerprinted filenames. access_log off on static assets reduces disk I/O from logging.
For caching headers and their interaction with security headers, see.
open_file_cache
Nginx can cache file descriptors, modification times, and existence checks for frequently accessed files. This avoids repeated stat() and open() system calls.
http {
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}
max=10000 holds up to 10,000 entries. Size this to match the number of static files you serve. If you serve 500 files, max=1000 is fine. If you serve 50,000 assets from a CDN origin, increase accordingly.
inactive=30s evicts entries not accessed in 30 seconds. open_file_cache_min_uses 2 only caches files accessed at least twice during the inactive window. This prevents one-off requests from polluting the cache.
open_file_cache_errors on caches 404 lookups too. If a client keeps requesting a nonexistent file, Nginx answers from cache instead of hitting the filesystem each time.
How do you enable gzip and Brotli compression in Nginx?
Gzip at level 4-6 gives the best tradeoff between CPU cost and compression ratio. Going above level 6 gains less than 2% compression while doubling CPU time. Brotli at level 4 typically achieves better compression than gzip at level 9, at similar CPU cost.
Gzip
http {
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_vary on;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
}
gzip_min_length 256 skips files smaller than 256 bytes. Compressing tiny files can produce output larger than the input due to gzip headers. gzip_vary on adds a Vary: Accept-Encoding header so caches store compressed and uncompressed versions separately. gzip_proxied any compresses responses even when the request came through a proxy.
Brotli
Brotli delivers 15-25% better compression than gzip on text assets. It is supported by all modern browsers. On Ubuntu 24.04 with the distro's Nginx package, install the module directly:
apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
On Debian 12 or when using Nginx mainline from nginx.org, the Brotli module is not included. You need to compile it as a dynamic module or use a third-party repository. The ngx_brotli repository has build instructions.
After the module is loaded, configure it:
http {
brotli on;
brotli_comp_level 4;
brotli_static on;
brotli_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
}
brotli_static on serves pre-compressed .br files if they exist. This lets you compress assets at build time with a higher compression level (e.g., 11) without paying the CPU cost at runtime.
brotli_comp_level 4 is the sweet spot for dynamic compression. Unlike gzip, Brotli's levels 1-4 are fast. Levels 5+ become much slower.
Compression comparison
| Content type | gzip level 5 ratio | Brotli level 4 ratio | Winner |
|---|---|---|---|
| HTML | 72% | 78% | Brotli |
| CSS | 80% | 85% | Brotli |
| JavaScript | 75% | 82% | Brotli |
| JSON | 78% | 83% | Brotli |
Ratios represent bytes saved relative to the original. Brotli consistently wins by 5-8 percentage points.
Both modules can run simultaneously. Nginx serves Brotli to clients that advertise Accept-Encoding: br and falls back to gzip for the rest.
How do you optimize TLS and HTTP/2 performance in Nginx?
TLS adds latency from handshakes and key exchange. Session caching, OCSP stapling, and TLS 1.3 minimize that overhead. HTTP/2 multiplexes requests over a single connection, eliminating head-of-line blocking at the HTTP layer.
HTTP/2
Since Nginx 1.25.1, the http2 parameter on the listen directive is deprecated. Use the http2 directive instead:
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
#...
}
TLS performance
http {
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 127.0.0.53 valid=300s;
resolver_timeout 5s;
}
ssl_session_cache shared:SSL:10m stores TLS session parameters in a 10 MB shared memory zone. One megabyte holds about 4,000 sessions. Returning clients skip the full TLS handshake and resume with a much cheaper operation.
ssl_session_tickets off is the safer default. Session tickets use a symmetric key that, if compromised, decrypts all past sessions (no forward secrecy). If you need tickets for multi-server setups, rotate keys frequently.
ssl_stapling on makes Nginx fetch and cache the OCSP response from your CA, then include it in the TLS handshake. The client does not need to contact the CA separately. This shaves 100-300ms off the first connection.
ssl_prefer_server_ciphers off is correct for TLS 1.3, where the client and server negotiate ciphers differently. For TLS 1.2 backward compatibility, the selected ciphers still matter, but TLS 1.3 cipher suites are all strong.
For the complete TLS and Let's Encrypt setup, see Set Up Let's Encrypt SSL/TLS for Nginx on Debian 12 and Ubuntu 24.04.
Which Linux kernel settings improve Nginx performance?
Four kernel parameters limit Nginx's ability to handle high connection volumes. The defaults are conservative for a general-purpose server. Tuning them removes bottlenecks at the OS level.
| Parameter | Default | Recommended | Why |
|---|---|---|---|
net.core.somaxconn |
4096 | 65535 | Maximum listen backlog queue. Low values cause connection drops under bursts. |
fs.file-max |
~100000 | 500000 | System-wide file descriptor limit. Each connection is a file descriptor. |
net.ipv4.tcp_tw_reuse |
0 | 1 | Reuse TIME_WAIT sockets for new connections. Speeds up connection cycling. |
net.ipv4.tcp_fastopen |
0 | 3 | Enables TCP Fast Open for both client and server. Saves one round trip on new connections. |
net.ipv4.ip_local_port_range |
32768 60999 | 1024 65535 | Expands ephemeral port range for outbound connections (proxy/upstream). |
net.core.netdev_max_backlog |
1000 | 16384 | Queue length for incoming packets when the interface receives faster than the kernel processes. |
Apply live without a reboot:
sysctl -w net.core.somaxconn=65535
sysctl -w fs.file-max=500000
sysctl -w net.ipv4.tcp_tw_reuse=1
sysctl -w net.ipv4.tcp_fastopen=3
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
sysctl -w net.core.netdev_max_backlog=16384
Make them persistent across reboots:
cat > /etc/sysctl.d/99-nginx-tuning.conf << 'EOF'
net.core.somaxconn = 65535
fs.file-max = 500000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fastopen = 3
net.ipv4.ip_local_port_range = 1024 65535
net.core.netdev_max_backlog = 16384
EOF
sysctl -p /etc/sysctl.d/99-nginx-tuning.conf
net.core.somaxconn = 65535
fs.file-max = 500000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fastopen = 3
net.ipv4.ip_local_port_range = 1024 65535
net.core.netdev_max_backlog = 16384
Also increase the per-process file descriptor limit for the Nginx systemd unit. Create an override:
mkdir -p /etc/systemd/system/nginx.service.d
cat > /etc/systemd/system/nginx.service.d/limits.conf << 'EOF'
[Service]
LimitNOFILE=65535
EOF
systemctl daemon-reload
systemctl restart nginx
cat /proc/$(pgrep -f 'nginx: master')/limits | grep "open files"
Max open files 65535 65535 files
How do you tune access log performance?
Writing a log line for every request consumes disk I/O. On high-traffic servers, access logging can become a bottleneck. Buffered logging writes to disk in batches.
http {
access_log /var/log/nginx/access.log combined buffer=64k flush=5s;
}
buffer=64k accumulates log entries in a 64 KB memory buffer. flush=5s writes the buffer to disk at least every 5 seconds, even if it is not full. This trades a few seconds of log delay for much less disk I/O.
If you do not need access logs for static assets (images, CSS, JS), disable them per location as shown in the caching section above.
How much faster is a tuned Nginx?
Run the same wrk benchmark after applying all changes. Test from the same machine, same parameters:
wrk -t4 -c200 -d30s https://your-server.example.com/
Running 30s test @ https://your-server.example.com/
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 5.21ms 2.34ms 42.56ms 81.23%
Req/Sec 9.78k 478.12 11.42k 68.75%
1171200 requests in 30.02s, 4.56GB read
Requests/sec: 39013.66
Transfer/sec: 155.61MB
Before vs. after
| Metric | Before | After | Change |
|---|---|---|---|
| Requests/sec | 16,442 | 39,014 | +137% |
| Avg latency | 12.34ms | 5.21ms | -58% |
| Max latency | 89.12ms | 42.56ms | -52% |
| Transfer/sec | 65.52 MB | 155.61 MB | +137% |
These numbers come from a 4-vCPU, 8 GB RAM Virtua Cloud VPS running Debian 12 and Nginx mainline, serving a static HTML page with CSS and JavaScript assets. Your results will vary based on workload, network conditions, and whether you proxy to a backend.
The biggest gains come from kernel sysctl (removes OS bottlenecks), worker/connection tuning (uses all available CPU), and compression (reduces bytes on the wire). TLS session caching and HTTP/2 have a smaller but measurable effect, especially on first-connection latency.
Complete tuned configuration
Full /etc/nginx/nginx.conf with all tuning applied:
user www-data;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
worker_processes auto;
worker_cpu_affinity auto;
worker_rlimit_nofile 8192;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# TCP optimization
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# Keepalive
keepalive_timeout 65;
keepalive_requests 1000;
# Buffers
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
client_body_buffer_size 16k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
# File cache
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# Gzip
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_vary on;
gzip_proxied any;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml;
# Brotli (if module installed)
# brotli on;
# brotli_comp_level 4;
# brotli_static on;
# brotli_types text/plain text/css text/javascript
# application/javascript application/json application/xml
# image/svg+xml;
# TLS
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
# Logging
access_log /var/log/nginx/access.log combined buffer=64k flush=5s;
error_log /var/log/nginx/error.log warn;
# Hide version
server_tokens off;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
server_tokens off hides the Nginx version from response headers and error pages. Version disclosure helps attackers target known vulnerabilities.
Something went wrong?
Check the error log first:
journalctl -u nginx -f
Common issues after tuning:
- "too many open files" in the error log:
worker_rlimit_nofileis lower thanworker_connections, or the systemdLimitNOFILEis not set. Check both. - "could not build optimal types_hash": increase
types_hash_max_sizeto 4096 in thehttpblock. - Brotli module not loading: run
nginx -V 2>&1 | grep brotlito check if the module is compiled in. If using dynamic modules, verifyload_moduledirectives are present at the top ofnginx.conf. - OCSP stapling not working: the first request after Nginx starts will not have a stapled response. Test with
openssl s_client -connect your-server:443 -status < /dev/null 2>&1 | grep -A 2 "OCSP Response". If it shows "no response sent", check thatssl_trusted_certificatepoints to the full chain andresolveris set. - wrk shows no improvement: make sure you test from a different machine. If testing over the internet, network latency dominates and obscures server-side gains. Test from a VPS in the same datacenter for accurate server performance numbers.
Copyright 2026 Virtua.Cloud. All rights reserved. This content is original work by the Virtua.Cloud team. Reproduction, republication, or redistribution without written permission is prohibited.
Ready to try it yourself?
Deploy your own server in seconds. Linux, Windows, or FreeBSD.
See VPS Plans