Nginx is the most-used web server and reverse proxy on the internet. This reference covers every config block you'll reach for — from basic serving to SSL termination, upstream load balancing, and performance tuning.
Quick reference
The 25 patterns that cover 95% of nginx work.
| Task | Key directive |
|---|---|
| Serve static files | root /var/www/html; try_files $uri $uri/ =404; |
| Listen on port 80 | listen 80; |
| Server name | server_name example.com www.example.com; |
| HTTPS redirect | return 301 https://$host$request_uri; |
| SSL certificate | ssl_certificate /etc/ssl/cert.pem; |
| Reverse proxy | proxy_pass http://localhost:3000; |
| Set proxy headers | proxy_set_header Host $host; |
| Gzip compression | gzip on; gzip_types text/plain application/json; |
| Cache static files | expires 1y; add_header Cache-Control "public"; |
| Rate limit zone | limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; |
| Apply rate limit | limit_req zone=api burst=20 nodelay; |
| Rewrite URL | rewrite ^/old/(.*)$ /new/$1 permanent; |
| Custom error page | error_page 404 /404.html; |
| Return 403 | return 403; |
| Upstream group | upstream backend { server 127.0.0.1:3000; } |
| Basic auth | auth_basic "Private"; auth_basic_user_file /etc/nginx/.htpasswd; |
| Client max body | client_max_body_size 50M; |
| Keepalive timeout | keepalive_timeout 65; |
| Worker processes | worker_processes auto; |
| Test config | nginx -t |
| Reload without downtime | nginx -s reload |
| View error log | tail -f /var/log/nginx/error.log |
| Block IP | deny 192.168.1.1; allow all; |
| Enable site (Debian) | ln -s /etc/nginx/sites-available/mysite /etc/nginx/sites-enabled/ |
| Disable default site | rm /etc/nginx/sites-enabled/default |
CLI commands
# Check nginx version
nginx -v
nginx -V # includes compiled modules
# Test configuration for syntax errors
nginx -t
nginx -T # test + print full merged config
# Start / stop / reload
systemctl start nginx
systemctl stop nginx
systemctl reload nginx # graceful reload — no dropped connections
systemctl restart nginx # full restart (drops connections briefly)
# Send signals directly
nginx -s reload # graceful reload
nginx -s quit # graceful shutdown
nginx -s stop # immediate shutdown
# View logs live
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log
Config file structure
/etc/nginx/
├── nginx.conf # main config (http block, includes)
├── conf.d/ # global snippets
├── sites-available/ # all server blocks (Debian/Ubuntu)
└── sites-enabled/ # symlinks to active sites
nginx.conf skeleton:
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
server_tokens off; # hide nginx version
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
Server blocks
A server block is nginx's equivalent of an Apache VirtualHost.
server {
listen 80;
listen [::]:80; # IPv6
server_name example.com www.example.com;
root /var/www/example;
index index.html index.php;
location / {
try_files $uri $uri/ =404;
}
}
try_files explained:
try_files $uri $uri/ =404;
# 1. Try the exact URI as a file
# 2. Try the URI as a directory (look for index)
# 3. Return 404 if neither found
try_files $uri $uri/ /index.php?$query_string;
# SPA / WordPress: fall through to index.php
Reverse proxy
Forward requests to a backend app (Node.js, Python, etc.).
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
# Essential headers
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;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering (disable for SSE/streaming)
proxy_buffering off;
}
}
WebSocket proxying:
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
SSL / TLS (Let's Encrypt)
Install Certbot, then let it generate the config:
apt install certbot python3-certbot-nginx
certbot --nginx -d example.com -d www.example.com
# Certbot edits your nginx config and sets up auto-renewal
Manual SSL server block:
server {
listen 443 ssl http2;
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;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
# Modern TLS settings
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;
add_header Strict-Transport-Security "max-age=63072000" always;
root /var/www/example;
# ... rest of your location blocks
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
Location blocks
Locations match request URIs. Matching order: exact (=) → prefix (^~) → regex (~, ~*) → longest prefix.
# Exact match (highest priority)
location = /favicon.ico {
access_log off;
log_not_found off;
}
# Prefix match (no regex fallback)
location ^~ /images/ {
root /var/www/static;
expires 30d;
}
# Case-sensitive regex
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Case-insensitive regex
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Prefix fallback (most general)
location / {
try_files $uri $uri/ =404;
}
Redirects and rewrites
# Permanent redirect (301)
return 301 https://example.com$request_uri;
return 301 /new-page;
# Temporary redirect (302)
return 302 /maintenance;
# Rewrite path (internal, no browser redirect)
rewrite ^/blog/(.+)$ /posts/$1 last;
# Rewrite with redirect (browser sees new URL)
rewrite ^/old-page$ /new-page permanent;
# Strip trailing slash
rewrite ^/(.*)/$ /$1 redirect;
# Non-www → www
server {
listen 80;
server_name example.com;
return 301 http://www.example.com$request_uri;
}
last vs break vs redirect vs permanent:
| Flag | Effect |
|---|---|
last |
Stop processing rewrites; re-search locations |
break |
Stop processing rewrites; continue in same location |
redirect |
302 temporary redirect to client |
permanent |
301 permanent redirect to client |
Load balancing
Distribute traffic across multiple backends.
upstream backend {
server 10.0.0.1:3000;
server 10.0.0.2:3000;
server 10.0.0.3:3000 weight=2; # gets 2x traffic
server 10.0.0.4:3000 backup; # only used if others are down
}
server {
location / {
proxy_pass http://backend;
}
}
Load balancing methods:
upstream backend {
least_conn; # send to server with fewest active connections
# ip_hash; # sticky sessions by client IP
# random; # random selection
server 10.0.0.1:3000;
server 10.0.0.2:3000;
}
Gzip compression
http {
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6; # 1 (fast) to 9 (best compression)
gzip_min_length 1024; # don't compress tiny responses
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Note: never gzip binary formats (images, video, zip)
}
Caching
Static file caching (browser):
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location ~* \.(jpg|jpeg|png|gif|ico|webp|svg)$ {
expires 30d;
add_header Cache-Control "public";
}
location / {
add_header Cache-Control "no-cache, must-revalidate";
}
Proxy caching (nginx stores backend responses):
http {
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=60m;
server {
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 10m;
proxy_cache_valid 404 1m;
proxy_cache_bypass $http_pragma;
add_header X-Cache-Status $upstream_cache_status;
}
}
}
Rate limiting
http {
# Define zone: 10MB stores ~160,000 IPs
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
server {
# Strict limit for login endpoint
location /api/login {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
# Generous limit for general API
location /api/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://backend;
}
}
}
burst and nodelay explained:
burst=20— allow 20 queued requests above the rate limitnodelay— process burst requests immediately (no artificial delay)- Without
nodelay: excess requests are delayed to meet the rate
Security headers
server {
# Hide nginx version
server_tokens off;
# 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 "camera=(), microphone=(), geolocation=()" always;
# Content Security Policy (adjust for your app)
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;
# HSTS (only after you're sure HTTPS works)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
Block IPs and requests
# Block specific IPs
location / {
deny 192.168.1.100;
deny 10.0.0.0/8; # deny entire subnet
allow all;
}
# Allow only specific IPs (whitelist)
location /admin {
allow 203.0.113.1;
deny all;
}
# Block bad user agents
if ($http_user_agent ~* "(curl|wget|python|scanner|bot)") {
return 403;
}
# Block requests without a Host header
if ($host = "") {
return 444; # nginx closes connection without response
}
PHP-FPM integration
server {
root /var/www/html;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
# Security: only execute PHP files that exist
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
# Or TCP: fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Deny access to sensitive files
location ~ /\.(ht|git|env) {
deny all;
}
}
SPA (React, Vue, Angular) hosting
server {
listen 80;
server_name app.example.com;
root /var/www/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
# Falls back to index.html for all routes
# Lets the frontend router handle navigation
}
# Cache hashed assets forever
location ~* \.[0-9a-f]{8}\.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
if ($variable = "value") |
The if directive in nginx is not like if in code — it has gotchas in location blocks |
Use map or return instead of if where possible |
Forgetting proxy_set_header Host |
Backend sees nginx host, not client's Host |
Always set proxy_set_header Host $host; |
add_header in child block |
Headers from parent are silently dropped | Repeat all add_header directives in every block that uses them |
root vs alias confusion |
root appends the location path; alias replaces it |
/static with root /var/www → /var/www/static; with alias /var/www → /var/www |
No nginx -t before reload |
Syntax error brings down all sites | Always nginx -t before nginx -s reload |
rewrite … last inside if |
Can cause infinite redirect loops | Use return for simple redirects; reserve rewrite for URL transformation |
Not setting worker_processes auto |
Defaults to 1 worker regardless of CPU cores | worker_processes auto; uses all cores |
Debugging checklist
# 1. Check syntax
nginx -t
# 2. View error log
tail -100 /var/log/nginx/error.log
# 3. Check if nginx is running
systemctl status nginx
# 4. Confirm listening ports
ss -tlnp | grep nginx
# 5. Test from server itself (bypasses firewall)
curl -I http://localhost
curl -I -k https://localhost # -k ignores SSL errors
# 6. Verbose connection test
curl -v http://example.com 2>&1 | head -50
# 7. Check which config file is active
nginx -T | head -20
# 8. Test a specific URL rewrite
curl -I http://localhost/old-path # follow redirects: curl -L
# 9. Firewall check (if curl from outside fails)
ufw status
iptables -L -n | grep -E "80|443"
FAQ
Q: What's the difference between nginx -s reload and systemctl restart nginx?
reload sends a SIGHUP signal: the master process starts new worker processes with the new config, then gracefully drains and kills old workers. No connections are dropped. restart stops nginx entirely and starts fresh — active connections are dropped. Always prefer reload in production.
Q: Why does add_header in a nested location block drop parent headers?
nginx's add_header only inherits from the parent if the child block has no add_header directives of its own. The moment you add one add_header in a location, all parent headers are discarded. Solution: repeat every add_header you need in each block, or use an include file.
# include /etc/nginx/security-headers.conf in every block that needs them
Q: What's the difference between root and alias?
# root: appends the full location path
location /static/ {
root /var/www;
# /static/file.css → /var/www/static/file.css
}
# alias: replaces the location path
location /static/ {
alias /var/www/assets/;
# /static/file.css → /var/www/assets/file.css
}
Q: How do I enable HTTP/2?
HTTP/2 requires HTTPS. Add http2 to your listen directive:
listen 443 ssl http2;
listen [::]:443 ssl http2;
HTTP/3 (QUIC) requires nginx compiled with --with-http_v3_module (nginx ≥ 1.25):
listen 443 quic reuseport;
add_header Alt-Svc 'h3=":443"; ma=86400';
Q: How do I serve multiple domains from one server?
Create separate server blocks, each with its own server_name. Nginx matches incoming requests by the Host header:
server {
server_name example.com;
root /var/www/example;
}
server {
server_name another.com;
root /var/www/another;
}
Q: How do I find which server block is handling a request?
Add a temporary header and check the response:
server {
add_header X-Debug-Server "this-block" always;
}
Then: curl -I http://example.com and look for X-Debug-Server.