Nginx Cheat Sheet
Production Nginx reference for platform and DevOps engineers running Nginx as a reverse proxy and TLS terminator in front of containerised or locally-running services. Covers config layout, hardened TLS, proxying and WebSockets, upstreams, security headers, rate limiting, caching, structured logging, and performance tuning.
Versions: nginx 1.26+ (stable) / OpenSSL 3.x. The
http2 on;directive requires nginx 1.25.1+ (older builds uselisten 443 ssl http2;). For the containers these configs proxy see Containers; for TLS certificate inspection and cipher testing see Security.
Config Layout & Commands
/etc/nginx/nginx.conf # main: events{} + http{} + includes
/etc/nginx/conf.d/*.conf # http-level configs (RHEL/Fedora default)
/etc/nginx/sites-available/ # vhosts (Debian/Ubuntu convention)
/etc/nginx/sites-enabled/ # symlinks to active vhosts
/etc/nginx/snippets/ # reusable include fragments (tls, headers)nginx -t # validate config syntax (ALWAYS before reload)
nginx -T # validate + dump the full effective config
nginx -V # build flags + compiled-in modules
nginx -s reload # graceful reload (or: systemctl reload nginx)
systemctl reload nginx # preferred on systemd hosts✅ Always run
nginx -tbeforereload. A reload of a broken config is rejected and the old workers keep serving, but a restart with a broken config leaves you down. Treatnginx -tas a mandatory pre-flight in deploy scripts.
Reverse Proxy (the core pattern)
A hardened vhost terminating TLS and proxying to an upstream, with correct forwarded headers, WebSocket support, and explicit timeouts. The include snippets are defined in the sections below.
upstream app {
server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
keepalive 32; # reuse upstream connections
}
server {
listen 443 ssl;
http2 on; # nginx 1.25.1+
server_name app.example.com;
ssl_certificate /etc/nginx/ssl/app.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/app.example.com/privkey.pem;
include snippets/tls.conf;
include snippets/security-headers.conf;
location / {
proxy_pass http://app;
proxy_http_version 1.1; # required for keepalive + WebSockets
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; # see WebSockets
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}⚠️ Mind the
proxy_passtrailing slash.proxy_pass http://app;(no path) preserves the request URI;proxy_pass http://app/;(with/) strips the matchedlocationprefix. Mismatched slashes are the most common “404 behind the proxy” cause.
HTTP to HTTPS Redirect
server {
listen 80;
listen [::]:80;
server_name app.example.com;
return 301 https://$host$request_uri; # 'return' is cheaper and clearer than 'rewrite'
}✅ Use
return 301for redirects, notrewrite.returnis unambiguous and avoids the regex engine; reserverewritefor genuine URL transformations.
TLS Hardening
Keep TLS settings in one snippet and include it from every SSL server block - one place to update when recommendations change.
# /etc/nginx/snippets/tls.conf
ssl_protocols TLSv1.2 TLSv1.3; # never TLSv1 / TLSv1.1
ssl_prefer_server_ciphers off; # let TLS1.3 clients choose
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;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on; # OCSP stapling
ssl_stapling_verify on;
# HSTS - only add once you are certain HTTPS is permanent for this host + subdomains
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;⚠️ HSTS with
includeSubDomains/preloadis sticky - browsers refuse plaintext formax-ageeven if you roll back. Ship it only when every subdomain is HTTPS-only, and start with a shortmax-agebefore raising it.
Security Headers
# /etc/nginx/snippets/security-headers.conf
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;
add_header Content-Security-Policy "default-src 'self'" always; # tune per app
server_tokens off; # hide version🚨
add_headerdoes not merge across levels. If you add even oneadd_headerinside alocation, alladd_headerdirectives inherited from theserver/httplevel are dropped for that location. Re-includethe headers snippet in any location that adds its own headers, and usealwaysso headers apply to error responses too.
Upstreams & Load Balancing
upstream api {
least_conn; # default is round-robin; also: ip_hash, random
server 10.0.0.11:8080 weight=3 max_fails=3 fail_timeout=10s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
server 10.0.0.13:8080 backup; # only used when others are down
keepalive 32; # connection pool (needs proxy_http_version 1.1)
}| Method | Directive | Use when |
|---|---|---|
| Round-robin | (default) | Stateless backends, even capacity |
| Least connections | least_conn; | Uneven/long-lived request durations |
| IP hash | ip_hash; | Sticky sessions without shared session store |
| Weighted | weight=N | Backends with different capacity |
🔬 Open-source Nginx only does passive health checks (
max_fails/fail_timeoutmark a peer down after failed real requests). Active health checks (health_check) are an Nginx Plus feature - on OSS, lean onmax_failsplus application-level readiness.
WebSockets & Streaming
# http context - maps the Upgrade header so non-WS requests don't get "Connection: upgrade"
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off; # stream SSE/WebSocket frames immediately
proxy_read_timeout 3600s; # long-lived connections
}✅ Use the
map $http_upgrade $connection_upgradepattern rather than hardcodingConnection "upgrade". Hardcoding sendsupgradeon every request, including plain HTTP, which some upstreams reject.
Static Files & Caching
location /static/ {
root /srv/app; # serves /srv/app/static/...
expires 30d;
add_header Cache-Control "public, immutable";
access_log off; # don't log asset noise
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html; # SPA fallback to index
}# http context - compression
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;🔬
rootvsalias:root /srv/app;inlocation /static/maps to/srv/app/static/...(the location is appended);alias /srv/app/assets/;replaces the matched prefix. Mixing them up serves the wrong path or 404s.
Rate & Connection Limiting
# http context - define shared-memory zones keyed on client IP
limit_req_zone $binary_remote_addr zone=req:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
server {
# apply where needed (login endpoints, APIs)
location /api/ {
limit_req zone=req burst=20 nodelay; # allow short bursts, reject beyond
limit_conn conn 20;
limit_req_status 429;
proxy_pass http://api;
}
}⚠️ Behind a load balancer or CDN,
$binary_remote_addris the proxy’s IP, so rate limits apply to all clients collectively. Use$http_x_forwarded_for(validated viaset_real_ip_from/real_ip_header) so limits key on the true client.
Logging
# http context - structured JSON access log (easy to ship to Loki/ELK/Sentinel)
log_format json escape=json
'{"time":"$time_iso8601","remote_addr":"$remote_addr",'
'"method":"$request_method","uri":"$request_uri","status":$status,'
'"bytes":$body_bytes_sent,"req_time":$request_time,'
'"upstream_time":"$upstream_response_time","host":"$host",'
'"xff":"$http_x_forwarded_for","ua":"$http_user_agent"}';
access_log /var/log/nginx/access.log json;
error_log /var/log/nginx/error.log warn;tail -F /var/log/nginx/access.log # -F survives logrotate
tail -F /var/log/nginx/error.log✅ Log
$upstream_response_timealongside$request_time. The gap between them tells you whether latency is in your backend or in Nginx/network, which is the first question in any “the site is slow” incident.
Performance Tuning
# nginx.conf - main + events + http
worker_processes auto; # one per CPU core
worker_rlimit_nofile 65535; # raise FD limit (also raise the systemd LimitNOFILE)
events {
worker_connections 4096;
multi_accept on;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 4096;
server_tokens off;
client_max_body_size 25m; # default is 1m - raise for uploads or get 413s
open_file_cache max=10000 inactive=60s; # cache file metadata for static serving
open_file_cache_valid 60s;
}🔬
worker_connectionsis per worker, so max simultaneous clients is roughlyworker_processes * worker_connections, halved for proxying (each client uses one connection to Nginx and one to the upstream). Raising it without also raisingworker_rlimit_nofileand the OS file-descriptor limit just produces “too many open files” errors.
Health & Status
location = /nginx_status {
stub_status; # active connections, requests, accepts
allow 127.0.0.1;
allow 10.0.0.0/8; # monitoring subnet
deny all;
}Location Matching Priority
Nginx does not pick the first match - it picks by modifier precedence:
| Modifier | Example | Precedence |
|---|---|---|
= | location = /healthz | Exact match, checked first (fastest) |
^~ | location ^~ /static/ | Prefix match, stops regex search if it wins |
~ / ~* | `location ~* .(jpg | css)$` |
| (none) | location /api/ | Plain prefix, lowest precedence |
🔬 Among regex locations the first match in file order wins, but a longer plain-prefix match is remembered and used only if no regex matches. Order your regex locations deliberately.
Anti-patterns
-
🚨
ssl_protocolsincludingTLSv1orTLSv1.1- deprecated protocols with known weaknesses (BEAST, POODLE). Use onlyssl_protocols TLSv1.2 TLSv1.3;. -
🚨
add_headerinside alocationwithout re-including inherited headers - anyadd_headerin a block silently drops alladd_headerdirectives from parent levels, so your security headers vanish on that route. Re-includethe headers snippet and usealways. -
⚠️ No HTTP to HTTPS redirect - without a port 80 block returning 301, browsers and crawlers are served plaintext. Always add the redirect server block.
-
⚠️ No
proxy_*_timeoutset - a hung upstream holds a worker connection open indefinitely and exhausts the pool under load. Set explicitproxy_connect_timeout,proxy_send_timeout, andproxy_read_timeout. -
⚠️ No security response headers - missing
X-Frame-Options,X-Content-Type-Options, andContent-Security-Policyleaves the app open to clickjacking and MIME-sniffing with no browser-level mitigation. -
⚠️ Leaving
client_max_body_sizeat the 1m default - uploads larger than 1 MB fail with a confusing413 Request Entity Too Large. Raise it on routes that accept uploads. -
⚠️
server_tokens on(the default) - it advertises the exact Nginx version in responses and error pages, helping attackers target known CVEs. Setserver_tokens off;. -
🔬 Wildcard
server_name _on a TLS vhost - TLS needs SNI matching; a catch-all HTTPS block serves the wrong certificate for unrecognised hostnames. Use explicitserver_namevalues on every TLS block. -
🔬
proxy_buffering on(default) for SSE/WebSocket/streaming - it delays delivery by buffering the response. Setproxy_buffering off;on streaming locations. -
🔬
ifinsidelocationfor anything butreturn/rewrite- Nginx’sifhas surprising scoping (“if is evil”); complex logic inside it misbehaves. Prefertry_files,map, or separatelocationblocks.
References
- Nginx documentation - directive reference by module
- Mozilla SSL Configuration Generator - current TLS settings for your Nginx + OpenSSL versions
- Nginx admin guide - reverse proxy, load balancing, security
- Pitfalls and common mistakes - the canonical “if is evil” / root vs alias guidance