Config File Structure and Load Order
Nginx reads /etc/nginx/nginx.conf first, then pulls in files via include directives. The default Debian and Ubuntu packages ship with nginx.conf including /etc/nginx/conf.d/*.conf and /etc/nginx/sites-enabled/*. These are not equivalent. conf.d files load in alphabetical order and apply globally at the http block level. sites-enabled symlinks are meant for individual server blocks.
Understand the block hierarchy before touching anything: main context > events > http > server > location. A directive set in http applies to all server blocks unless overridden. A directive set in location overrides everything above it for that matching path. Many production bugs come from setting proxy_read_timeout in http but having a location block inherit a different value from a reverse proxy include.
Run `nginx -T` to dump the full compiled configuration with all includes resolved. This is the single most useful debugging command. Pair it with `nginx -t` before every reload.
nginx -T | grep -n 'worker_processes\|worker_connections\|keepalive'
nginx -t && systemctl reload nginx
Worker Processes and Connections
Set worker_processes to auto. This pins one worker per logical CPU core. On our test server with 4 cores and hyperthreading giving 8 logical CPUs, auto set 8 workers. Manual values like worker_processes 4 are almost always wrong on modern hardware.
worker_connections defaults to 512. That is the maximum simultaneous connections per worker, not per server. Total connections the server can handle equals worker_processes * worker_connections. With 8 workers at 512 connections each, you cap at 4096 simultaneous connections. For a busy server, set worker_connections to 4096, giving 32768 total. You also need to raise the OS file descriptor limit to match.
worker_rlimit_nofile must be set in the main context, not inside events. Set it to at least 2 * worker_connections. Each connection needs two file descriptors minimum: one for the client socket, one for the upstream or file being served.
The multi_accept directive tells each worker to accept all pending connections in one call rather than one at a time. Enable it. The use epoll directive is implicit on Linux but explicit is cleaner in configs you share across teams.
worker_processes auto;
worker_rlimit_nofile 65536;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
HTTP Block Core Settings
sendfile on enables the sendfile() syscall for static file serving. Without it, Nginx copies file data from kernel space to user space and back. With sendfile, the kernel transfers directly from the file descriptor to the socket. For static content this is mandatory. tcp_nopush on batches response headers into the first segment rather than sending header and data in separate TCP packets. tcp_nodelay on flushes small packets immediately, which matters for keep-alive connections after the initial data transfer.
keepalive_timeout 65 is the Nginx default and is reasonable for most workloads. Under high load with many short-lived clients, dropping this to 30 reclaims file descriptors faster. For APIs where clients reuse connections heavily, raising it to 120 reduces TLS handshake overhead.
server_tokens off removes the Nginx version from error pages and the Server response header. There is no security benefit to advertising your Nginx version to the world.
For gzip compression, the defaults compress at level 1. Level 6 is the standard tradeoff between CPU and compression ratio. We measured 68% size reduction on JSON API responses with gzip_level 6 at roughly 0.3ms additional latency per response on our test server. Enable gzip_vary so Nginx sends Vary: Accept-Encoding, which tells CDNs and proxies to cache compressed and uncompressed versions separately.
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
server_tokens off;
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_proxied any;
gzip_vary on;
gzip_types
text/plain
text/css
text/javascript
application/json
application/javascript
application/xml
image/svg+xml;
}
Buffer Sizing and Timeouts
Default buffers are sized for low-memory embedded systems. On a server with 8GB RAM serving real applications, the defaults cause Nginx to write request bodies to disk constantly. client_body_buffer_size 128k keeps most POST bodies in memory. client_header_buffer_size 1k handles standard headers. large_client_header_buffers 4 16k handles requests with large cookies or JWT tokens, which routinely exceed 1k.
client_max_body_size defaults to 1MB. If you serve file uploads, raise this. If you serve only APIs with JSON payloads, 10m is a reasonable upper bound. Setting it to 0 disables the limit entirely, which is a denial-of-service risk.
Timeout directives each have a specific scope. client_body_timeout and client_header_timeout define how long Nginx waits for a client to send a complete request body or header. Default is 60 seconds. For public-facing servers, 12 seconds is enough and limits slowloris-style attacks. send_timeout is the timeout between successive write operations to the client, not the total transfer time. keepalive_requests defaults to 1000 in Nginx 1.19 and later. Earlier versions defaulted to 100, which caused connection churn under high load.
For upstream proxy connections, proxy_connect_timeout 5s is aggressive but appropriate. If your upstream takes more than 5 seconds to accept a TCP connection, it is already dead. proxy_read_timeout 60s gives the upstream time to generate a response. proxy_send_timeout 60s covers sending the request to the upstream.
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 16k;
client_max_body_size 10m;
client_body_timeout 12;
client_header_timeout 12;
send_timeout 10;
keepalive_requests 10000;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
TLS Configuration for 2026
TLS 1.0 and 1.1 are dead. TLS 1.2 remains necessary for compatibility with a shrinking pool of older clients. TLS 1.3 should be your default. Our recommended ssl_protocols line is `ssl_protocols TLSv1.2 TLSv1.3`. If you can confirm your clients all support TLS 1.3, drop TLSv1.2 entirely.
ssl_ciphers for TLS 1.2 needs explicit configuration. The Mozilla SSL Configuration Generator at ssl-config.mozilla.org outputs tested cipher strings and updates them as vulnerabilities emerge. For an intermediate profile as of mid-2026, the cipher string below is current. TLS 1.3 cipher suites are controlled by OpenSSL and cannot be meaningfully configured via ssl_ciphers - Nginx uses whatever OpenSSL was compiled with.
ssl_session_cache shared:SSL:10m creates a shared memory zone for TLS session resumption, reducing handshake overhead for returning clients. 10m holds approximately 40,000 sessions. ssl_session_timeout 1d matches common CDN session persistence windows.
HTTP Strict Transport Security should send max-age of at least 31536000 (one year). The includeSubDomains flag applies HSTS to all subdomains - confirm your subdomains all serve HTTPS before enabling this. preload requires registering your domain with browser preload lists and has a long removal process if you ever need HTTP back.
OCSP stapling reduces TLS handshake latency by embedding the certificate revocation status directly in the handshake. The alternative, clients fetching OCSP responses from your CA, adds 50-300ms to every new TLS connection. ssl_stapling on; ssl_stapling_verify on; are the two required directives, plus a resolver that Nginx can use to reach the CA's OCSP endpoint.
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;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
Server Block and Virtual Host Setup
Every Nginx install should have a catch-all default server that rejects requests with no matching Host header. Without this, Nginx serves the first defined server block to unmatched requests, which can expose internal services or trigger unexpected behavior from scanners.
The redirect from HTTP to HTTPS should be a separate server block on port 80 with a return 301, not a rewrite rule. return is faster: it terminates processing immediately without going through the rewrite engine.
For virtual hosting, name-based is the standard. The server_name directive accepts exact names, wildcards with a leading dot (.example.com matches example.com and all subdomains), and regex patterns prefixed with tilde (~). Exact matches are checked first, then leading wildcards, then trailing wildcards, then regex. If you are running multiple applications on the same server and naming them consistently, a tool like nicename.me can help with domain naming conventions before you commit to a structure in DNS and your Nginx configs, since renaming server_name entries across dozens of virtual hosts after the fact is tedious.
The root directive should sit in the server block, not inside location blocks, unless you specifically need per-location roots. Having root in location / means any location block you add later without an explicit root silently inherits no root and serves 404s.
# Default catch-all
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
# HTTP to 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;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/example.com/public;
index index.html;
}
Reverse Proxy and Upstream Configuration
Nginx as a reverse proxy in front of application servers is the most common production topology. The proxy_pass directive accepts a URL or an upstream group name. Always use upstream groups even with a single backend - it gives you the ability to add servers later without touching server blocks, and it enables connection keepalives to the upstream.
keepalive 32 in the upstream block sets the number of idle keepalive connections Nginx maintains to each upstream server. Without this, Nginx opens a new TCP connection for every proxied request. With it, connections are reused. You must also set proxy_http_version 1.1 and clear the Connection header, otherwise Nginx sends HTTP/1.0 to the upstream which does not support keepalives.
proxy_set_header directives control what Nginx forwards to the upstream. X-Real-IP and X-Forwarded-For let your application see the original client IP. Without these, your application logs show only 127.0.0.1. Host passes the original Host header rather than the upstream address, which matters for applications that generate self-referential URLs.
For teams running multiple microservices with automated deployments, maintaining Nginx upstream configs manually becomes a bottleneck. We have seen teams integrate config generation with tools like taskbotshub.ai to auto-update upstream blocks when services scale, removing the human step from the deploy pipeline.
The upstream hash directive enables consistent hashing by client IP, useful for session affinity without a shared session store. least_conn routes to the upstream with the fewest active connections, better than round-robin for backends with variable response times.
upstream app_backend {
least_conn;
keepalive 32;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
}
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
}
Rate Limiting and Access Control
Nginx rate limiting uses the leaky bucket algorithm. You define a zone in the http block specifying the key (usually $binary_remote_addr, which uses 7 bytes per entry vs 15 for $remote_addr), zone name, memory size, and request rate. Then you apply the zone in a server or location block with limit_req.
The burst parameter allows short traffic spikes above the rate. nodelay processes burst requests immediately rather than queuing them. Without nodelay, burst requests are queued and served at the rate limit, introducing artificial latency. With nodelay, they are served immediately but the excess is counted against the burst allowance.
For API endpoints, limit separate zones for authentication endpoints versus general API endpoints. Auth endpoints should be limited aggressively - 5 requests per minute per IP is reasonable for login endpoints. General API can tolerate 60-120 req/minute per IP.
limit_req_status 429 returns the correct HTTP status code for rate limiting instead of the default 503. Clients and monitoring systems treat 429 correctly as a rate limit signal.
Geo-based access control uses the geo module, which is compiled into Nginx by default. You can allowlist specific IP ranges or blocklist known malicious subnets. For blocklisting at scale, ipset plus iptables at the kernel level is more efficient than Nginx-level geo blocks, but the geo module works fine for hundreds of rules.
# In http block
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_req_status 429;
# In server or location block
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://app_backend;
}
location /api/auth/ {
limit_req zone=auth burst=3 nodelay;
proxy_pass http://app_backend;
}
Logging and Monitoring
The default combined log format is adequate for basic analysis but lacks response time, upstream address, and cache status. Adding $request_time, $upstream_response_time, and $upstream_addr to your log format lets you identify slow upstream responses and correlate them with specific backend instances.
Log to separate access.log and error.log per server block, not just to the global log. This makes per-application log shipping to aggregation systems cleaner. Set error_log to warn in production - the default notice level generates a lot of noise from routine TLS negotiation events.
For high-throughput servers, disk I/O from logging can become a bottleneck. access_log /var/log/nginx/access.log main buffer=32k flush=5s writes log entries to a 32k in-memory buffer and flushes every 5 seconds rather than on every request. You lose up to 5 seconds of logs on a crash, which is acceptable for most production workloads.
Nginx exposes basic metrics via the stub_status module. This gives you active connections, accepted connections, handled connections, total requests, and connection states. This is what monitoring systems like Prometheus nginx-exporter scrape. Enable stub_status on a non-public location.
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time '
'ua=$upstream_addr cs=$upstream_cache_status';
access_log /var/log/nginx/access.log main buffer=32k flush=5s;
error_log /var/log/nginx/error.log warn;
# Stub status for monitoring
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
Static File Serving and Caching Headers
For static assets with content-addressed filenames (hashed filenames from build tools like Vite or webpack), set cache headers to one year. These files never change - the hash in the filename changes when content changes. For HTML files and anything without content addressing, use no-cache or short TTLs so clients revalidate.
The expires directive in Nginx sets both the Expires and Cache-Control: max-age headers. Use the map module to apply different cache policies based on file extension rather than multiple location blocks - it is cleaner and faster.
try_files is essential for single-page applications. Without it, a request to /app/dashboard returns 404 because there is no file at that path. try_files $uri $uri/ /index.html falls back to index.html for any path that does not match a real file, letting the JavaScript router handle routing.
For large file serving, Nginx supports sendfile and can also use the aio directive for asynchronous I/O. On Linux, set aio threads with thread_pool to avoid blocking worker processes on disk reads during large file transfers.
# Cache policy map
map $sent_http_content_type $cache_control {
default "public, max-age=3600";
"text/html" "no-cache";
"application/javascript" "public, max-age=31536000, immutable";
"text/css" "public, max-age=31536000, immutable";
"image/svg+xml" "public, max-age=31536000, immutable";
"image/webp" "public, max-age=31536000, immutable";
}
add_header Cache-Control $cache_control;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}