1. How Nginx Works — Event-Driven Architecture
Apache assigns one thread (or process) per connection. Under 10,000 concurrent users that is 10,000 threads — each consuming stack memory, CPU time-slices, and OS scheduler overhead. Nginx takes a different approach: it runs a small, fixed pool of worker processes (one per CPU core is the typical setting). Each worker uses a non-blocking event loop to manage thousands of connections simultaneously, spending no CPU time on idle connections.
This makes Nginx dramatically more efficient under high concurrency. A single Nginx worker can handle 10,000+ simultaneous connections. For static file serving and reverse proxying, Nginx consistently outperforms Apache 5–10x on throughput benchmarks at high concurrency. Understanding this model explains why the tuning parameters below exist.
2. Config File Structure
Nginx configuration is a hierarchy of contexts. Directives are inherited from parent contexts and can be overridden in child contexts.
# Main context — global settings
user www-data;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
# Connection handling
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
# Shared settings for all virtual hosts
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
# Virtual host (site) configuration
listen 80;
server_name example.com;
location / {
# Request routing rules
}
}
}
In most Linux distributions, the main nginx.conf includes /etc/nginx/conf.d/*.conf or /etc/nginx/sites-enabled/*. Keep your site-specific configuration in a separate file — one server block per file — and never edit the main nginx.conf directly beyond global settings.
Essential Commands
nginx -t — test configuration for syntax errors before reloading
nginx -s reload — reload config without dropping connections (graceful)
nginx -s stop — fast shutdown
systemctl reload nginx — same as reload via systemd
3. Worker & Connection Tuning
These settings live at the top of nginx.conf and in the events block. They have the largest impact on throughput and latency under high load.
worker_processes auto; # one per CPU core
worker_rlimit_nofile 65535; # max open files per worker (match ulimit -n)
events {
worker_connections 4096; # max simultaneous connections per worker
use epoll; # Linux kernel I/O event model (fastest on Linux)
multi_accept on; # accept all pending connections at once
}
http {
sendfile on; # use kernel sendfile() — zero-copy for static files
tcp_nopush on; # send headers in one packet with sendfile
tcp_nodelay on; # disable Nagle for low-latency keepalive responses
keepalive_timeout 65; # keep connection open for 65s after last request
keepalive_requests 1000; # max requests per keepalive connection
types_hash_max_size 2048;
server_tokens off; # hide Nginx version in headers (security)
# Optimise buffer sizes for typical responses
client_body_buffer_size 128k;
client_max_body_size 20m; # max upload size
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on; # cache 404s too — prevents repeated disk lookups
}
| Directive | Why It Matters | Recommended Value |
|---|---|---|
worker_processes | One worker per CPU core saturates the hardware without context-switch overhead | auto |
worker_connections | Total max connections = worker_processes × worker_connections | 1024–8192 depending on RAM |
sendfile | Bypasses userspace buffer copy for static files — cuts CPU ~30% for file-heavy sites | on |
keepalive_timeout | Amortises TCP handshake over multiple requests; too high holds connections open unnecessarily | 15–65 seconds |
open_file_cache | Caches file descriptors, reducing syscalls for repeated static file requests | max=10000 inactive=30s |
4. Gzip & Brotli Compression
Compression reduces transferred bytes by 60–80% for text content. The tradeoff is CPU — compression levels 1–6 are fast enough that the bandwidth saving nearly always wins.
gzip on;
gzip_vary on; # Vary: Accept-Encoding header for CDNs
gzip_proxied any; # compress proxied responses too
gzip_comp_level 6; # 1 (fast) – 9 (best); 6 is the sweet spot
gzip_min_length 1000; # skip files smaller than 1KB — not worth it
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_disable "msie6"; # disable for ancient IE6
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
application/atom+xml
image/svg+xml
font/ttf
font/opentype
application/vnd.ms-fontobject;
# Do NOT add image/jpeg, image/png, image/webp — already compressed
Brotli — Better Compression for Modern Browsers
Brotli (developed by Google) achieves 15–25% better compression than gzip at equivalent speeds for most text content. It requires the ngx_brotli module — not included in the default Nginx build but available in many package managers.
brotli on;
brotli_comp_level 6;
brotli_static on; # serve pre-compressed .br files if they exist
brotli_types
text/plain text/css text/javascript
application/javascript application/json
application/xml image/svg+xml;
Gzip vs Brotli
Serve Brotli where supported (all modern browsers) and fall back to gzip. Nginx with ngx_brotli handles this automatically — it checks the Accept-Encoding header and serves the best available format.
5. Static File Serving & Browser Caching
Browser caching eliminates repeat downloads. The strategy is: set very long max-age for fingerprinted assets (cache-busted by filename hash), and short or no-cache for HTML files that must reflect deployments immediately.
server {
listen 443 ssl http2;
server_name example.com;
root /var/www/html;
# HTML — always revalidate (fast check, short cache)
location ~* \.html?$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Hashed/fingerprinted assets — cache forever
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Images, fonts — long cache
location ~* \.(jpg|jpeg|png|gif|webp|ico|svg|woff|woff2|ttf|eot)$ {
expires 6M;
add_header Cache-Control "public";
add_header Vary "Accept-Encoding";
access_log off;
}
# Disable access log for favicon and robots
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# Deny access to hidden files (.git, .env, etc.)
location ~ /\. { deny all; }
}
The Immutable Directive
immutable tells the browser: "this file will never change during its cache lifetime — don't even send a conditional request." Only use it on assets where the filename changes on every deploy (hashed filenames like app.a3f9c1d.js). Never use it on filenames that stay the same across deployments.
6. Reverse Proxy Setup
A reverse proxy forwards client requests to a backend application — Node.js, PHP-FPM, Python (Gunicorn/uWSGI), Java (Tomcat), or any process listening on a local port or Unix socket. Nginx handles the public-facing connection, SSL, compression, and rate limiting while the backend focuses on business logic.
upstream nodejs_backend {
server 127.0.0.1:3000;
keepalive 32; # keep 32 connections open to backend
}
server {
listen 443 ssl http2;
server_name api.example.com;
location / {
proxy_pass http://nodejs_backend;
# Pass real client info to the backend
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# HTTP/1.1 required for keepalive to upstream
proxy_http_version 1.1;
proxy_set_header Connection "";
# Timeouts
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering — disable for streaming/SSE, keep on for normal requests
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
}
PHP-FPM via Unix Socket
Unix sockets are faster than TCP loopback for same-host PHP-FPM communication — no TCP overhead, no port binding.
server {
listen 443 ssl http2;
server_name www.example.com;
root /var/www/html;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Performance
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_connect_timeout 10s;
fastcgi_send_timeout 60s;
fastcgi_read_timeout 60s;
}
}
try_files — Critical for PHP Apps
try_files $uri $uri/ /index.php?$query_string first tries to serve a real file, then a real directory, and only falls back to PHP if neither exists. This avoids passing static file requests to PHP-FPM and is required by most PHP frameworks (Laravel, WordPress, CodeIgniter).
7. Proxy Caching — FastCGI Cache & proxy_cache
Nginx can cache backend responses on disk and in memory — serving cached pages at static-file speed with zero backend load. For high-traffic PHP sites, FastCGI caching can reduce backend hits by 90%+.
FastCGI Cache for PHP
# ── Define cache zone in http block ──────────────────────────
fastcgi_cache_path /var/cache/nginx/fcgi
levels=1:2
keys_zone=php_cache:100m # 100MB shared memory for keys
max_size=1g # 1GB disk cache
inactive=60m # purge entries not accessed in 60 min
use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# ── In server block ───────────────────────────────────────────
server {
set $skip_cache 0;
# Skip cache for logged-in users, POST requests, cart/checkout
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~* "/wp-admin|/wp-login|/cart|/checkout|/account") {
set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_logged_in|woocommerce_items_in_cart") {
set $skip_cache 1;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_cache php_cache;
fastcgi_cache_valid 200 301 302 60m; # cache 200/301/302 for 60 min
fastcgi_cache_use_stale error timeout updating
http_500 http_503; # serve stale on backend failure
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_lock on; # one request rebuilds cache, others wait
add_header X-Cache-Status $upstream_cache_status; # debug: HIT/MISS/BYPASS
}
}
Proxy Cache for Node.js / Backend APIs
# http block
proxy_cache_path /var/cache/nginx/proxy
levels=1:2
keys_zone=api_cache:50m
max_size=500m
inactive=30m
use_temp_path=off;
proxy_cache_key "$scheme$host$request_uri$http_accept_encoding";
# server/location block
location /api/public/ {
proxy_pass http://nodejs_backend;
proxy_cache api_cache;
proxy_cache_valid 200 10m; # cache successful responses 10 min
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_502 http_503 http_504;
proxy_cache_background_update on; # update in background, serve stale immediately
proxy_cache_lock on;
# Only cache GET and HEAD — never cache POST/PUT/DELETE
proxy_cache_methods GET HEAD;
add_header X-Cache-Status $upstream_cache_status;
}
X-Cache-Status Header
Add add_header X-Cache-Status $upstream_cache_status during development. Check the response in browser DevTools: HIT = served from cache, MISS = fetched from backend, BYPASS = cache skipped, EXPIRED = stale entry being refreshed. Remove or restrict this header in production to avoid leaking cache topology.
8. SSL/TLS Configuration & HTTP/2
A secure, performant TLS setup requires selecting modern protocols and cipher suites, enabling session resumption, and configuring OCSP stapling. Using Let's Encrypt with Certbot handles certificate issuance automatically.
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
# Certificate files (Let's Encrypt path)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
# Modern TLS only — no TLS 1.0 / 1.1
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# Session resumption — reduces handshake overhead for returning clients
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m; # ~40,000 sessions
ssl_session_tickets off; # disabled for perfect forward secrecy
# OCSP stapling — attach certificate status to TLS handshake
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
# HSTS — force HTTPS for 1 year, include subdomains
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Diffie-Hellman params (generate with: openssl dhparam -out /etc/nginx/dhparam.pem 2048)
ssl_dhparam /etc/nginx/dhparam.pem;
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
HTTP/2 vs HTTP/3 (QUIC)
HTTP/2 (listen 443 ssl http2) multiplexes multiple requests over one TCP connection, eliminating head-of-line blocking at the HTTP layer. HTTP/3 uses QUIC (UDP-based) to eliminate TCP head-of-line blocking entirely — supported in Nginx 1.25+ with listen 443 quic reuseport. For most production sites, HTTP/2 is the right choice today.
9. Load Balancing
Nginx's upstream module distributes requests across multiple backend servers. This is used for horizontal scaling (multiple app server instances) and for blue-green deployments.
# ── Round Robin (default) — each request to next server ──────
upstream app_servers {
server 10.0.0.1:3000;
server 10.0.0.2:3000;
server 10.0.0.3:3000;
keepalive 32;
}
# ── Least Connections — new request to server with fewest active ─
upstream app_least_conn {
least_conn;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
# ── IP Hash — sticky sessions (same client → same server) ────
upstream app_sticky {
ip_hash;
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
# ── Weighted — send more traffic to faster servers ────────────
upstream app_weighted {
server 10.0.0.1:3000 weight=3; # gets 3× more requests
server 10.0.0.2:3000 weight=1;
}
# ── Health checks & failover ──────────────────────────────────
upstream app_with_failover {
server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
server 10.0.0.3:3000 backup; # only used when primary servers are down
}
| Strategy | Best For | Limitation |
|---|---|---|
| Round Robin | Stateless apps, equal-capacity servers | Ignores actual server load |
| Least Connections | Long-lived requests (WebSockets, file uploads) | Requires Nginx Plus for active health checks |
| IP Hash | Session-based apps without Redis session storage | Poor distribution if clients share a NAT IP |
| Weighted | Mixed-capacity fleet (some servers have more CPU/RAM) | Static weights — doesn't adapt to load |
10. Rate Limiting & DDoS Mitigation
Rate limiting restricts how many requests a client can make in a time window. It protects login endpoints from brute-force attacks, limits API abuse, and reduces impact from bot traffic.
# ── Define zones in http block ───────────────────────────────
# $binary_remote_addr uses 7 bytes vs 15+ for $remote_addr — more efficient
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
# ── Apply in server/location blocks ──────────────────────────
server {
# Connection limit — max 20 concurrent connections per IP
limit_conn perip 20;
limit_conn perserver 1000;
# General pages — burst of 40 allowed, delayed after 20r/s
location / {
limit_req zone=general burst=40 nodelay;
}
# Login endpoint — strict: 5 requests/min, burst of 10
location /login {
limit_req zone=login burst=10 nodelay;
limit_req_status 429; # return 429 Too Many Requests
}
# API endpoints — 100 req/min with small burst
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
add_header Retry-After 60;
}
}
nodelay vs delay
nodelay processes burst requests immediately without queuing — better for API responses where latency matters. Without nodelay, burst requests are queued and delayed to match the rate — better for protecting backends from sudden spikes without dropping requests.
11. Security Hardening
A few targeted Nginx directives significantly reduce the attack surface of your web server.
# ── Hide server information ───────────────────────────────────
server_tokens off; # don't expose Nginx version
# ── Security headers ──────────────────────────────────────────
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy
"default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'"
always;
# ── Block common attack patterns ──────────────────────────────
# Block requests with suspicious query strings
if ($query_string ~* "union.*select|select.*from|insert.*into|delete.*from|drop.*table|exec\(|cast\(|convert\(|0x[0-9a-f]") {
return 403;
}
# Deny access to sensitive files
location ~ /\.(git|env|htaccess|htpasswd|DS_Store) {
deny all;
return 404;
}
location ~* \.(bak|sql|dump|tar|gz|zip|log)$ {
deny all;
return 404;
}
# Block bad bots and scanners
if ($http_user_agent ~* "nikto|sqlmap|nmap|masscan|zgrab|python-requests|curl/7.") {
return 403;
}
# Limit HTTP methods — only allow what your app uses
if ($request_method !~ ^(GET|POST|HEAD|OPTIONS|PUT|DELETE)$) {
return 405;
}
12. Complete Production Config Example
The following combines all the above into a single production-ready Nginx configuration for a PHP application with FastCGI caching, SSL/TLS, gzip, browser caching, rate limiting, and security headers.
# FastCGI cache zone — defined before server blocks
fastcgi_cache_path /var/cache/nginx/fcgi
levels=1:2 keys_zone=FCGI:100m max_size=2g inactive=60m use_temp_path=off;
# Rate limit zones
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=perip:10m;
# ── HTTP → HTTPS redirect ─────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# ── Main HTTPS server ─────────────────────────────────────────
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
root /var/www/html;
index index.php;
# TLS
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
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;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 valid=300s;
# Security headers
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Rate limiting
limit_conn perip 30;
limit_req zone=general burst=60 nodelay;
# Gzip
gzip on; gzip_vary on; gzip_comp_level 6; gzip_min_length 1000;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# Static files — long cache
location ~* \.(css|js|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location ~* \.(jpg|jpeg|png|gif|webp|ico|svg)$ {
expires 6M;
add_header Cache-Control "public";
access_log off;
}
# Deny hidden files
location ~ /\. { deny all; return 404; }
# Routing
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Strict rate limit for login
location ~ ^/(login|wp-login) {
limit_req zone=login burst=10 nodelay;
try_files $uri /index.php?$query_string;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# PHP — with FastCGI caching
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($request_uri ~* "/admin|/login|/cart|/checkout|/account") {
set $skip_cache 1;
}
if ($http_cookie ~* "logged_in|session|cart") {
set $skip_cache 1;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_buffers 16 16k; fastcgi_buffer_size 32k;
fastcgi_cache FCGI;
fastcgi_cache_valid 200 60m;
fastcgi_cache_use_stale error timeout updating http_500;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
}
}
Testing & Monitoring Your Config
Test syntax: nginx -t before every reload.
Reload gracefully: systemctl reload nginx
Check cache hits: grep "X-Cache-Status: MISS" /var/log/nginx/access.log | wc -l
Check error rate: tail -f /var/log/nginx/error.log
Test SSL grade: SSL Labs — aim for A+.
Test performance: ab -n 1000 -c 50 https://example.com/ (Apache Bench)