Config File Literacy: Nginx
- Config file structure
- Main context — global settings
- events block
- http block
- server block — virtual hosts
- location blocks
- Reverse proxy directives
- TLS directives
- Logging
- include — splitting config across files
- Annotated full config
- Testing config before applying
- try_files for SPA / static routing
- server_tokens and security headers
- set_real_ip_from — real client IP
- upstream health: max_fails / fail_timeout
- Rate limiting: limit_req_zone
- HTTP/2 and HTTP/3 knobs
Config file structure
Nginx config is hierarchical. Directives live inside contexts (blocks surrounded by { }). Contexts can be nested. Directives in an outer context apply to all inner contexts unless overridden.
# Structure overview:
main context (no surrounding braces)
├── events { ... }
└── http {
├── upstream { ... }
└── server {
├── server_name directive
├── root directive
└── location / {
└── proxy_pass directive
}
}
}
Main context — global settings
user nginx; # which OS user nginx worker processes run as
worker_processes auto; # number of workers; auto = one per CPU core
error_log /var/log/nginx/error.log warn; # global error log
pid /run/nginx.pid; # PID file location
worker_processes auto — for most servers, leave this on auto. Only tune manually on hosts with many CPU-intensive connections.
events block
events {
worker_connections 1024; # max concurrent connections per worker
# total max = worker_processes × worker_connections
use epoll; # I/O model (epoll is best for Linux — usually auto-detected)
multi_accept on; # accept multiple connections per wakeup
}
http block
The http block wraps all web server config. Directives here apply to all server blocks unless overridden.
http {
include /etc/nginx/mime.types; # maps file extensions to Content-Type headers
default_type application/octet-stream; # fallback Content-Type
sendfile on; # use kernel sendfile for serving static files (faster)
tcp_nopush on; # send headers + start of body in one packet
keepalive_timeout 65; # how long to keep an idle connection open (seconds)
gzip on; # compress responses
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 256; # only compress responses above this size
# Include all server block files from conf.d/
include /etc/nginx/conf.d/*.conf;
}
server block — virtual hosts
Each server block defines one virtual host. Nginx matches incoming connections to server blocks by the listen port and server_name.
server {
listen 80; # which port to listen on
listen [::]:80; # same but for IPv6
server_name app.example.com; # hostname(s) this block responds to
# supports wildcards: *.example.com
# can list multiple: app.example.com www.example.com
root /var/www/app; # filesystem root for this virtual host
index index.html; # default file to serve
access_log /var/log/nginx/app.access.log; # per-vhost access log
error_log /var/log/nginx/app.error.log; # per-vhost error log
}
If no server_name matches the incoming Host: header, nginx falls back to the first server block (or one with default_server). This is why you sometimes need to set a catch-all server block.
location blocks
Location blocks match URL paths and define how to handle them. The matching syntax:
location / { } # prefix match — matches everything starting with /
location = /exact { } # exact match — only /exact
location ~ \.php$ { } # regex match (case sensitive)
location ~* \.jpg$ { } # regex match (case insensitive)
Matching priority (highest to lowest): exact match (=) → ^~ prefix (wins over regex) → first matching regex (~ / ~*, tested in config order) → longest plain prefix match.
server {
listen 80;
server_name app.example.com;
root /var/www/app;
# Exact match — fastest for the health check endpoint
location = /health {
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Serve static files directly, without going to the backend
location /static/ {
alias /var/www/static/; # filesystem path to serve from
expires 30d; # cache-control header
add_header Cache-Control "public";
}
# Everything else goes to the app backend
location / {
proxy_pass http://127.0.0.1:8080;
}
}
Reverse proxy directives
When nginx forwards requests to a backend:
location / {
proxy_pass http://127.0.0.1:8080; # backend address
# can also be a named upstream block
# Required for HTTP/1.1 keepalive to backends (see upstream section below)
proxy_http_version 1.1;
proxy_set_header Connection ""; # clear the Connection header (default is "close")
# Pass the 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; # tells backend if client used https
# Timeouts
proxy_connect_timeout 60s; # how long to wait for backend to accept connection
proxy_send_timeout 60s; # how long to wait for backend to accept data
proxy_read_timeout 60s; # how long to wait for backend to send response
# Buffer settings
proxy_buffering on;
proxy_buffer_size 4k;
}
keepalive in an upstream block but don't add proxy_http_version 1.1; and proxy_set_header Connection ""; in the location block, keepalive silently falls back to HTTP/1.0 and connections close after every request, defeating the purpose. Always include both lines when using upstream keepalive.
X-Forwarded-For is how the backend application knows the original client IP even though it is talking to nginx. Without this header, all requests appear to come from 127.0.0.1.
TLS directives
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name app.example.com;
ssl_certificate /etc/ssl/certs/app.example.com.crt; # full chain cert
ssl_certificate_key /etc/ssl/private/app.example.com.key; # private key
# Modern TLS settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# HSTS — tells browsers to always use HTTPS
add_header Strict-Transport-Security "max-age=63072000" always;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
Logging
http {
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/access.log main;
# Disable access log for health checks (reduces log noise)
server {
location = /health {
access_log off;
return 200;
}
}
}
Common nginx log variables:
$remote_addr— client IP (or proxy IP if behind a load balancer)$request— HTTP method, URI, protocol:GET /path HTTP/1.1$status— HTTP response code$body_bytes_sent— response size in bytes$http_x_forwarded_for— original client IP if set by upstream proxy$upstream_response_time— how long the backend took to respond
include — splitting config across files
# In nginx.conf
http {
include /etc/nginx/conf.d/*.conf; # all .conf files in conf.d/
include /etc/nginx/sites-enabled/*; # Debian convention
}
# Each virtual host gets its own file:
# /etc/nginx/conf.d/app.example.com.conf
# /etc/nginx/conf.d/api.example.com.conf
The include directive is processed at load time. If any included file has a syntax error, nginx will fail to start. Run nginx -t after editing any file.
Annotated full config
# /etc/nginx/conf.d/app.example.com.conf
# Upstream block — defines backend servers
# Allows load balancing across multiple backends
upstream app_backend {
server 127.0.0.1:8080 weight=1; # primary
server 127.0.0.1:8081 weight=1; # secondary (load balanced)
keepalive 16; # keep 16 connections to backends alive
}
# HTTPS virtual host
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/ssl/certs/app.example.com.crt;
ssl_certificate_key /etc/ssl/private/app.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
access_log /var/log/nginx/app.access.log;
error_log /var/log/nginx/app.error.log;
client_max_body_size 64m; # max upload size
# Health check endpoint
location = /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Static assets — served directly by nginx
location /static/ {
root /var/www; # serves /var/www/static/*
expires 7d;
}
# API — forwarded to backend
location /api/ {
proxy_pass http://app_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 https;
proxy_read_timeout 120s;
}
# Everything else — forwarded to backend
location / {
proxy_pass http://app_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 https;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
Testing config before applying
# Syntax check — safe, no changes
nginx -t
# Syntax check with verbose output
nginx -T # shows full config including all includes
# Reload (apply config changes without dropping connections)
systemctl reload nginx
# Restart (needed for some changes like SSL cert replacement)
systemctl restart nginx
try_files for SPA / static routing
try_files checks for files on disk in order and falls back to the last argument if none exist. It is the standard solution for single-page applications (React, Vue, Angular) where the browser always requests / for every client-side route.
# SPA routing — always serve index.html for unknown paths
location / {
root /var/www/app;
try_files $uri $uri/ /index.html;
# 1. Try the exact URI as a file
# 2. Try the URI as a directory (serves index.html inside it)
# 3. Fall back to /index.html for all other paths
}
# Static file server with 404 fallback
location /static/ {
root /var/www;
try_files $uri =404; # 404 if file not found
}
# API + SPA on same server: route /api/ to backend, everything else to SPA
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location / {
root /var/www/app;
try_files $uri $uri/ /index.html;
}
server_tokens and security headers
By default nginx reveals its version in Server: response headers and error pages. Hiding this is a basic hardening step.
# In http {} or server {} block
server_tokens off; # removes nginx version from Server: header and error pages
Common security headers
server {
server_tokens off;
# Prevent clickjacking
add_header X-Frame-Options SAMEORIGIN always;
# Prevent MIME type sniffing
add_header X-Content-Type-Options nosniff always;
# Enable XSS filter (older browsers)
add_header X-XSS-Protection "1; mode=block" always;
# HTTPS only (HSTS) — only add after SSL is confirmed working
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Referrer policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
Add always to ensure headers are sent on error responses (4xx, 5xx) as well as 2xx responses.
set_real_ip_from — real client IP behind a load balancer
When nginx sits behind a load balancer (AWS ELB, Cloudflare, HAProxy) or CDN, $remote_addr shows the load balancer's IP, not the client's. The ngx_http_realip_module restores the real IP.
# In http {} block — tell nginx which IPs are trusted proxies
# Replace with your actual LB / CDN CIDR ranges
# AWS ALB
set_real_ip_from 10.0.0.0/8;
# Cloudflare (see https://www.cloudflare.com/ips/ for current list)
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
# ... (add all Cloudflare ranges)
# Which header to read the real IP from
real_ip_header X-Forwarded-For;
# Take the rightmost IP in X-Forwarded-For that is NOT in the trusted list
# (prevents IP spoofing via X-Forwarded-For header manipulation)
real_ip_recursive on;
After this config, $remote_addr and $http_x_real_ip in your logs will show the actual client IP. This is also required for rate limiting by client IP to work correctly when behind a load balancer.
upstream health: max_fails / fail_timeout
Passive health checking — nginx marks an upstream server "unavailable" after max_fails failed attempts within fail_timeout, then retries it after the same window elapses. No active probes, no extra module — it just observes the in-band traffic.
upstream app_backend {
# server addr:port [parameters];
server 10.0.1.11:8080 max_fails=3 fail_timeout=15s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=15s;
# Standby — only used when all primaries are marked down
server 10.0.1.99:8080 backup;
keepalive 32;
}
max_fails=3— after 3 connection errors or 5xx responses (seeproxy_next_upstream) in the window, the server is taken out of rotation.fail_timeout=15s— both the evaluation window and how long the server stays marked down before nginx retries it.max_fails=0disables the check (server is always considered up).
Only the commercial nginx Plus supports active health checks (health_check directive). For open-source nginx, pair passive checks with a short proxy_connect_timeout and proxy_next_upstream error timeout http_502 http_503 http_504; so failed requests retry the next peer automatically.
Rate limiting: limit_req_zone
Token-bucket rate limiting keyed on any nginx variable — typically client IP. Declare the zone in http {}, apply it in a server or location with limit_req.
http {
# 10 MB zone ≈ 160 000 unique $binary_remote_addr entries.
# rate = average allowed rate (tokens per second)
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
# A stricter zone for login endpoints, keyed on IP+URI.
limit_req_zone $binary_remote_addr$uri zone=login:10m rate=1r/s;
server {
listen 443 ssl;
server_name app.example.com;
# Global limit: 10 req/s per IP, burst of 20, no delay on burst
limit_req zone=perip burst=20 nodelay;
# Return 429 instead of the default 503 when throttled
limit_req_status 429;
location /api/login {
# Stack another limiter on top: 1 req/s per IP+URI, burst 5
limit_req zone=login burst=5 nodelay;
proxy_pass http://app_backend;
}
}
}
burst lets short spikes through by queueing excess requests; nodelay makes those burst requests flow immediately (instead of being spaced out to match rate). Without nodelay, legitimate bursts feel artificially slow. Log rejections by adding $limit_req_status to your log_format.
HTTP/2 and HTTP/3 knobs
HTTP/2 has been stable since nginx 1.9.5. HTTP/3 (QUIC over UDP) is shipped as a first-class feature from nginx 1.25.0 (May 2023) — no third-party patches needed, though your build still has to be compiled with the QUIC-enabled OpenSSL fork or BoringSSL.
# HTTP/2 — mature, enable everywhere you terminate TLS
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name app.example.com;
# Tune concurrency — defaults (128) are fine for most sites
http2_max_concurrent_streams 128;
keepalive_timeout 65;
keepalive_requests 1000;
}
# HTTP/3 / QUIC (nginx 1.25+) — experimental until you measure it
server {
listen 443 ssl; # TCP: HTTP/1.1 + HTTP/2
listen 443 quic reuseport; # UDP: HTTP/3
listen [::]:443 quic reuseport;
http2 on; # 1.25+ syntax (replaces listen ... http2;)
http3 on; # enable HTTP/3 handling for this server
# Advertise HTTP/3 availability to HTTP/2 clients so they can upgrade.
# 86400 = seconds the browser may cache the Alt-Svc record.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_certificate /etc/ssl/certs/app.example.com.crt;
ssl_certificate_key /etc/ssl/private/app.example.com.key;
ssl_protocols TLSv1.3; # HTTP/3 requires TLS 1.3
}
firewall-cmd --add-port=443/udp) and any upstream load balancer or security group. If UDP/443 is blocked, clients silently fall back to HTTP/2 over TCP and you gain nothing except extra log lines. Verify with curl --http3 -I https://app.example.com/ (curl built against a QUIC library).