Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
NGINX performance tuning: the settings that actually matter NGINX performance tuning that makes a measurable difference: worker and file limits, upstream keepalive, compression, caching, TLS and HTTP/2, and what to skip.
A site starts timing out at lunchtime. The server's CPU sits at 20 per cent, memory is fine, and the error log says "worker_connections are not enough". Someone has already pasted a 200-line nginx.conf from a forum. Nothing improved, because the real limit was a default nobody had checked.
Short answer: NGINX performance tuning for most sites comes down to a handful of settings. Let workers scale to the CPU and raise connection and file limits. Keep connections to the backend alive. Compress text and cache static assets for a long time. Add a small proxy cache in front of slow pages. Measure before and after each change.
Everything below applies to current NGINX releases (1.26 and later) on a typical Linux VPS. We list the settings in the order we check them on servers we look after, then the popular tweaks that rarely make a difference.
Measure first, or you are guessing Before changing anything, find out where the time goes by logging request time and upstream response time. If upstream time accounts for nearly all of the request time, the bottleneck is your application or database, and no amount of NGINX tuning will fix it.
log_format timed '$remote_addr "$request" $status $body_bytes_sent '
'rt=$request_time urt=$upstream_response_time '
'cache=$upstream_cache_status';
access_log /var/log/nginx/access.log timed buffer=64k flush=5s;
# Connection counters on a private port
server {
listen 127.0.0.1:8081;
location = /nginx_status { stub_status; }
}The stub_status page shows active, reading, writing and waiting connections, which is enough to see whether you are near your connection limit. For load tests, run wrk, oha or k6 from a separate machine and change one thing at a time. Run nginx -t after every edit, and nginx -T to see the full configuration NGINX has loaded, including every included file.
Workers, connections and file limits This is the section that fixes the lunchtime timeout above. Three directives decide how many clients NGINX can hold open at once.
worker_processes auto; # one worker per CPU core
worker_rlimit_nofile 65535; # open-file limit for each worker
events {
worker_connections 8192;
multi_accept on;
}worker_processes auto starts one worker per core. More workers than cores adds context switching, not throughput. worker_connections is per worker, and it counts every connection, including the one NGINX opens to your backend. A proxied request uses two. The default of 512 or 768 in many distribution configs is too low for a busy site. worker_rlimit_nofile must be higher than worker_connections, because each connection is a file descriptor and so are open files and log files. Setting it here means you do not depend on the systemd unit's limit. Leave the event method alone. NGINX picks epoll on Linux automatically. When this ceiling is reached, the error log says so. Check it before raising anything else.
Keepalive to clients and to your backend A TCP connection plus a TLS handshake costs far more than serving a small response, and keepalive avoids paying it repeatedly. On the client side the defaults are reasonable: keepalive_timeout is 75 seconds and keepalive_requests is 1000. Lowering the timeout to around 30 seconds frees idle connections sooner on busy servers.
The setting most often missing is keepalive to the upstream. Unless told otherwise, most NGINX installs open a new connection to your Node, Python or PHP backend for every request and close it afterwards. Under load this adds latency and can exhaust local ports, which shows up as sockets stuck in TIME_WAIT. Recent mainline releases have started changing some proxy defaults, so we set these explicitly.
upstream app {
server 127.0.0.1:3000;
keepalive 32; # idle connections kept per worker
}
server {
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
}If the proxy speaks HTTP/1.0 and sends Connection: close, the keepalive line silently does nothing. The number is the count of idle connections each worker keeps, not a cap on total connections, so 16 to 64 suits most single-backend setups.
Static files and compression NGINX is very fast at serving files from disk if you let it. These defaults belong in the http block of almost every server.
sendfile on;
tcp_nopush on;
tcp_nodelay on;
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_vary on;
gzip_proxied any;
gzip_types text/css application/javascript application/json
image/svg+xml text/plain text/xml application/xml;sendfile lets the kernel copy file data straight to the socket. tcp_nopush packs headers and the start of the file into full packets. open_file_cache keeps file descriptors and metadata in memory, which helps when the same assets are requested thousands of times. If you deploy new files often, keep the validity period short.
For compression, level 5 is a good trade-off; higher levels cost noticeably more CPU for a few per cent smaller output. text/html is always compressed, so it does not need listing. Leave out images, WOFF2 fonts and video, which are already compressed. If your build step produces .gz files, gzip_static on serves them with no CPU cost per request. Brotli usually gives smaller text files but needs a third-party module, so we only add it where the distribution maintains a package.
Then tell browsers to keep assets. Files with a content hash in the name never change, so they can be cached for a year. HTML should be revalidated so new deploys show up straight away.
location ~* \.(?:css|js|woff2|svg|png|jpg|webp|avif)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache";
}One trap: if a location block has any add_header line, it no longer inherits add_header directives from the server level. Security headers set once for the whole site quietly disappear from your assets. Repeat them in the location or keep them in a shared include.
A proxy cache for slow pages If your application renders the same page for every anonymous visitor, NGINX can store that response and serve it without calling the backend. Even a very short lifetime, known as micro-caching, helps: with the ten-second setting below, a hundred identical requests per second become one backend request every ten seconds.
proxy_cache_path /var/cache/nginx/app levels=1:2 keys_zone=appcache:10m
max_size=1g inactive=60m use_temp_path=off;
server {
location / {
proxy_cache appcache;
proxy_cache_valid 200 301 10s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_bypass $cookie_sessionid $http_authorization;
proxy_no_cache $cookie_sessionid $http_authorization;
add_header X-Cache $upstream_cache_status;
proxy_pass http://app;
}
}keys_zone holds cache keys in shared memory. According to the NGINX documentation, one megabyte stores about 8,000 keys, so 10m covers most sites. proxy_cache_use_stale with updating serves the old copy while one request refreshes it, and keeps the site up for a while if the backend fails. proxy_cache_lock stops a burst of requests for an expired page all reaching the backend at once. The bypass lines are the important ones. Anything personal, such as a logged-in page, a basket or an admin screen, must never come from the cache. Use your application's real session cookie name. By default NGINX does not cache responses that set a cookie or send Cache-Control: private, no-cache or no-store. Cache-Control and Expires headers from the backend also take priority over proxy_cache_valid. Test with a logged-in account and watch the X-Cache header before you rely on any of this.
TLS and HTTP/2 without the folklore Modern TLS is cheap on current CPUs. What costs time is repeated full handshakes, so let returning clients resume their sessions. Enable HTTP/2 with the http2 directive, which replaced the old listen ... http2 parameter in version 1.25.1.
server {
listen 443 ssl;
http2 on;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m; # about 40,000 sessions
ssl_session_timeout 1d;
ssl_session_tickets off;
}Two 2026-specific notes. Let's Encrypt has shut down its OCSP service, so ssl_stapling does nothing for its certificates and only adds warnings to the log. HTTP/3 over QUIC is available through listen 443 quic and an Alt-Svc header, but it needs a build with the HTTP/3 module and UDP 443 open in your firewall. It helps most for mobile users on lossy networks, so treat it as an optional extra once the basics are done.
What to leave alone, and a checklist Many tuning guides spend most of their length on changes that make no measurable difference on a normal VPS. We leave these alone unless a measurement points at them:
Kernel sysctl changes such as somaxconn. Linux 5.4 and later already default it to 4096, and NGINX's own listen backlog defaults to 511 on Linux, so raising somaxconn alone changes nothing. worker_cpu_affinity and worker_priority. The scheduler does a good job by itself. Very large proxy_buffers values. Raise proxy_buffer_size only if the error log reports "upstream sent too big header", which usually means large cookies or JWTs. Turning off proxy_buffering everywhere. Keep it on, except for streaming responses such as server-sent events, where you can set X-Accel-Buffering: no from the application. The order we work through on a server:
Log request time and upstream time, then decide whether NGINX is the bottleneck at all. Set worker_processes auto, raise worker_connections and set worker_rlimit_nofile above it. Enable upstream keepalive with HTTP/1.1 and an empty Connection header. Turn on sendfile, gzip for text types and long cache headers for hashed assets. Add a short proxy cache for anonymous pages, with bypass rules for sessions. Enable HTTP/2 and TLS session caching, and drop OCSP stapling for Let's Encrypt certificates. Change one thing at a time, run nginx -t, reload, and measure again. If pages are still slow after all of this, the time is being spent in the application, the database or a third-party call. That is where profiling and query analysis take over from NGINX configuration.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789