Nginx is a formidably efficient web server right out of the box. It handles thousands of simultaneous connections without breaking a sweat, where other servers buckle. But the default configuration stays generic: it has to work everywhere, from a Raspberry Pi to a 64-core server. In monitoring Linux, you can do much better.
After optimizing Nginx across several dozen Docker en production servers, I've identified 7 areas of improvement that consistently make a difference. Each optimization comes with concrete, tested configurations that are ready to deploy. We're not talking about theoretical micro-optimizations, but changes you can measure in response times and load capacity.
Golden rule: always measure before and after every change. An optimization you haven't measured is just a guess.
1. Worker processes and connections
The first thing to configure is the workers. By default, Nginx often launches a single worker process, which completely underuses a multi-core server. Each worker is an independent process capable of handling thousands of connections thanks to the event-driven model.
Optimal worker configuration
# /etc/nginx/nginx.conf - Main block
# One worker per available CPU core
worker_processes auto;
# Pin each worker to a specific core (avoids context switching)
worker_cpu_affinity auto;
# Max number of open files per worker
worker_rlimit_nofile 65535;
events {
# Simultaneous connections per worker
# Rule: worker_connections * worker_processes = total max connections
worker_connections 4096;
# Use epoll on Linux (far more performant than select/poll)
use epoll;
# Accept multiple connections in a single iteration
multi_accept on;
}
The worker_processes auto directive automatically detects the number of CPU cores. On an 8-core server with 4096 connections per worker, you get a theoretical capacity of 32,768 simultaneous connections. In practice, that's more than enough for the majority of sites.
Check the system limits
These settings are useless if the operating system throttles Nginx. You need to adjust the kernel limits:
# Check the current open-files limit
ulimit -n
# Increase it in /etc/security/limits.conf
echo "nginx soft nofile 65535" | sudo tee -a /etc/security/limits.conf
echo "nginx hard nofile 65535" | sudo tee -a /etc/security/limits.conf
# Increase the queue of pending TCP connections
sudo sysctl -w net.core.somaxconn=65535
sudo sysctl -w net.ipv4.tcp_max_syn_backlog=65535
2. Gzip and Brotli compression
Compression drastically reduces the size of the responses sent to clients. A 100 KB HTML file often compresses to 15-20 KB, an 80% reduction. On mobile connections or low-bandwidth links, the impact is immediate.
Optimized Gzip configuration
http {
# Enable Gzip compression
gzip on;
# Compress for proxies too
gzip_proxied any;
# Compression level (1-9): 4-6 is the best CPU/compression trade-off
gzip_comp_level 5;
# Minimum size to trigger compression (pointless below 256 bytes)
gzip_min_length 256;
# Tell caches that the content varies by Accept-Encoding
gzip_vary on;
# MIME types to compress
gzip_types
text/plain
text/css
text/javascript
text/xml
application/json
application/javascript
application/xml
application/xml+rss
application/atom+xml
application/vnd.ms-fontobject
font/opentype
image/svg+xml
image/x-icon;
}
Add Brotli to go further
Brotli offers a compression ratio 15 to 20% better than Gzip for textual content. Every modern browser supports it. The ideal approach is to pre-compress static files with brotli_static to avoid on-the-fly compression:
# Brotli module (requires ngx_brotli compiled in)
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# Serve pre-compressed .br files if they exist
brotli_static on;
# Pre-compress static assets with Brotli
find /var/www/site/assets -type f \( -name "*.css" -o -name "*.js" -o -name "*.svg" \) \
-exec brotli --best --keep {} \;
3. Aggressive static caching
Browser caching is the most underrated optimization. A visitor who returns to your site should never re-download the CSS, JavaScript or image files that haven't changed. The benefit is twofold: faster load times for the user, and a lighter server load.
Caching strategy by file type
server {
# Versioned assets (with a hash in the name): very long cache
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Cache-Strategy "immutable-asset";
}
# Images and fonts: long cache
location ~* \.(jpg|jpeg|png|gif|webp|avif|ico|woff2|woff|ttf|eot)$ {
expires 6M;
add_header Cache-Control "public, no-transform";
access_log off;
}
# SVG files and favicons
location ~* \.(svg|svgz)$ {
expires 1y;
add_header Cache-Control "public";
add_header Content-Encoding gzip;
}
# HTML pages: short cache or mandatory revalidation
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}
}
The immutable header is especially powerful: it tells the browser that the file will never change at that URL. Combined with an asset versioning system (a hash in the file name), it's the most effective caching strategy possible.
4. Buffers and timeouts
Buffers control how Nginx manages memory for each connection. Buffers that are too small force temporary disk writes (slow); buffers that are too large waste RAM. Timeouts protect against ghost connections that consume resources.
Buffer configuration
http {
# Buffers for client request bodies
client_body_buffer_size 16k;
client_max_body_size 50m;
client_header_buffer_size 1k;
large_client_header_buffers 4 16k;
# Buffers for backend responses (proxy_pass)
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# Temporary files (if buffers overflow)
proxy_temp_file_write_size 256k;
}
Tuned timeouts
http {
# Timeout for reading the client header
client_header_timeout 15s;
# Timeout for reading the client body
client_body_timeout 15s;
# Timeout for sending the response to the client
send_timeout 15s;
# Keep-alive connections: keep them open but not indefinitely
keepalive_timeout 30s;
keepalive_requests 1000;
# Timeouts for backends
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_send_timeout 15s;
}
The keepalive_timeout is crucial. Too low (5s), and clients have to constantly reopen TCP connections, which is expensive with TLS. Too high (120s), and idle connections pile up and saturate the workers. A value of 30 seconds is a good compromise for most cases.
5. HTTP/2 and optimized TLS
HTTP/2 brings multiplexing (several requests over a single TCP connection), header compression and server push. But its benefits depend on a correct TLS configuration. A poorly configured TLS setup can wipe out all the gains from HTTP/2.
Fast, secure TLS configuration
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
# Certificates
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Protocols: TLS 1.2 and 1.3 only
ssl_protocols TLSv1.2 TLSv1.3;
# Optimized cipher suites
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# TLS session cache (avoids a full handshake on every connection)
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP Stapling: the server provides proof of the certificate's validity
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
}
OCSP Stapling is often forgotten. Without it, the browser has to contact the certificate authority to verify the certificate's validity, adding 100 to 300 ms to the first load. With stapling, Nginx provides that information directly, eliminating the latency.
6. Rate limiting and protection
A production server without rate limiting is an easy target. Even without a massive DDoS attack, a simple script looping over your pages can saturate your backends. Nginx natively includes effective limiting mechanisms.
Define the limiting zones
http {
# Per-IP limiting zone: 10 requests per second
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
# Dedicated zone for login pages (more restrictive)
limit_req_zone $binary_remote_addr zone=login:10m rate=2r/s;
# Limit the number of simultaneous connections per IP
limit_conn_zone $binary_remote_addr zone=addr:10m;
}
server {
# Apply the general limit with an allowed burst
limit_req zone=general burst=20 nodelay;
# Maximum 50 simultaneous connections per IP
limit_conn addr 50;
# Custom error page for rate-limited requests
error_page 429 /429.html;
# Hardened protection on login
location /login {
limit_req zone=login burst=5 nodelay;
limit_conn addr 10;
proxy_pass http://backend;
}
# Block suspicious user-agents
if ($http_user_agent ~* (bot|crawler|scanner|nikto|sqlmap)) {
return 403;
}
}
Basic anti-DDoS protection
# Limit request size to avoid saturation attacks
client_max_body_size 10m;
client_body_timeout 10s;
client_header_timeout 10s;
# Close slow connections (slowloris)
reset_timedout_connection on;
# Block requests with no Host header (scanners)
server {
listen 80 default_server;
server_name _;
return 444;
}
The 444 return code is specific to Nginx: it closes the connection without sending any response. It's more efficient than a 403 because the server wastes no bandwidth replying to malicious requests.
7. Monitoring and observability
Optimizing without monitoring is flying blind. Nginx provides native metrics with the stub_status module, and you can go much further with structured logs and a Prometheus export.
Enable stub_status
# Basic metrics endpoint (restricted access)
server {
listen 127.0.0.1:8080;
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
}
# Query the metrics
curl http://127.0.0.1:8080/nginx_status
# Typical output:
# Active connections: 291
# server accepts handled requests
# 16630948 16630948 31070465
# Reading: 6 Writing: 179 Waiting: 106
Structured JSON logs
Classic text logs are hard to parse. A JSON format allows direct integration with tools like Loki, Elasticsearch or Datadog:
log_format json_combined escape=json
'{"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request_method":"$request_method",'
'"request_uri":"$request_uri",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_response_time":"$upstream_response_time",'
'"http_user_agent":"$http_user_agent",'
'"http_referer":"$http_referer",'
'"ssl_protocol":"$ssl_protocol",'
'"ssl_cipher":"$ssl_cipher"}';
access_log /var/log/nginx/access.json json_combined buffer=32k flush=5s;
Metrics for Prometheus
For advanced monitoring, the nginx-prometheus-exporter module exposes the metrics in Prometheus format. It builds on stub_status and adds detailed metrics:
# Install the exporter
wget https://github.com/nginxinc/nginx-prometheus-exporter/releases/latest/download/nginx-prometheus-exporter_linux_amd64.tar.gz
tar xzf nginx-prometheus-exporter_linux_amd64.tar.gz
# Run the exporter
./nginx-prometheus-exporter -nginx.scrape-uri=http://127.0.0.1:8080/nginx_status
# Metrics are available at http://localhost:9113/metrics
# nginx_connections_active, nginx_http_requests_total, etc.
Summary and results
Here's a recap of the 7 optimizations and their typical impact measured on a production server (4 cores, 8 GB RAM, NVMe SSD) serving a site with around 50,000 visitors per day:
- Workers and connections: processing capacity multiplied by the number of cores
- Gzip/Brotli compression: 70 to 85% bandwidth reduction for textual content
- Aggressive static caching: 60 to 80% fewer server requests for returning visitors
- Buffers and timeouts: elimination of temporary disk writes and zombie connections
- HTTP/2 and optimized TLS: 200 to 400 ms saved on the first load thanks to OCSP Stapling and the session cache
- Rate limiting: protection against abuse with no impact on legitimate traffic
- Monitoring: full visibility to catch problems before they become critical
In our benchmarks, all of these optimizations together took us from 850 requests/second to 3,200 requests/second on the same hardware, with the average Time to First Byte (TTFB) cut to a third.
# Quick benchmark with wrk
wrk -t4 -c200 -d30s https://example.com/
# Before optimization:
# Requests/sec: 847.23 | Avg Latency: 236ms
# After optimization:
# Requests/sec: 3214.56 | Avg Latency: 62ms
Apply these optimizations one at a time, measuring the impact at each step. Start with the workers and compression: those are the most immediate gains. Then tune the buffers, the cache and the TLS. Rate limiting and monitoring come last, but don't neglect them.
Every server is different, every application has its own specifics. The values suggested here are solid starting points, but the best configuration is always the one that is measured and tuned to your real-world load.
Comments