Getting Started
Basic Configuration
Nginx config uses a hierarchical block structure. worker_processes sets process count. The http block contains server blocks, which contain location blocks. Always test with nginx -t before reloading.
# /etc/nginx/nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen 80;
server_name example.com;
location / {
root /var/www/html;
index index.html;
}
}
}
# Test config: nginx -t
# Reload: nginx -s reload
# Start: systemctl start nginxDirectives & Contexts
Contexts nest: main > events/http/mail > server > location. Simple directives end with semicolon; block directives use braces. Some directives are only valid in specific contexts.
# Contexts are hierarchical blocks
# Global context (main) - outside any block
user nginx;
worker_processes auto;
# events context
events {
worker_connections 1024;
}
# http context
http {
# server context
server {
listen 80;
# location context
location / {
root /var/www/html;
}
}
}
# Directives: simple (name value;) or block (name { ... })Testing & Reloading
nginx -t checks syntax AND tries to open referenced files (certs, logs). Always run it before reload. reload sends SIGHUP — workers finish current requests then reload. restart causes brief downtime.
# test configuration for syntax errors
nginx -t
# test and print full config
nginx -T
# reload config without dropping connections
nginx -s reload
# stop gracefully (finishes current requests)
nginx -s quit
# stop immediately
nginx -s stop
# reopen log files (after log rotation)
nginx -s reopen
# systemd alternative
systemctl reload nginx
systemctl restart nginxMain vs HTTP Context
worker_processes and events live in main context. Routing, proxying, caching, and server blocks live in http. SSL, logging, and gzip can be set in http and inherited by server/location.
# main context - global settings
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
}
http {
# all HTTP-related config lives here
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
include /etc/nginx/conf.d/*.conf;
}Include Files
includes keep config modular. The conf.d glob (*.conf) is the modern convention. sites-enabled with symlinks is the Debian/Ubuntu pattern. mime.types maps file extensions to Content-Type.
# main config: /etc/nginx/nginx.conf
http {
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
# /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name example.com;
root /var/www/html;
}
# Disable a site: remove the file from sites-enabled/
# Available sites kept in sites-available/, symlinked into enabled/Command-line Operations
nginx -V shows compile-time options including which modules are built in. -g sets directives that override config. -c is useful for testing alternate configs without touching the default.
# start nginx (if not managed by systemd)
nginx
# specify a custom config file
nginx -c /path/to/nginx.conf
# set a global directive from command line
nginx -g "worker_processes 4;"
# test config
nginx -t
# print version and build configuration
nginx -V
# send signal to master process
nginx -s reload
nginx -s stopServer Blocks
Basic Server Block
A server block defines a virtual host. listen sets the port. server_name matches the Host header. root sets the document root. index specifies which files to serve for directory requests.
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example;
index index.html index.htm;
location / {
try_files $uri $uri/ =404;
}
}Multiple Server Names
Nginx matches server_name by exact, then wildcard (*.foo.com), then regex. The first matching server block handles the request. Use separate blocks for different apps on the same port.
server {
listen 80;
server_name example.com www.example.com;
# ...
}
server {
listen 80;
server_name api.example.com;
# ...
}
# Wildcard: matches any single-level subdomain
server_name *.example.com;
# Regex (starts with ~)
server_name ~^www\d+\.example\.com$;Default Server
default_server handles requests whose Host header matches no server_name. The underscore is a convention (invalid hostname, never matches real traffic). return 444 closes the connection without a response.
# mark a server block as default for the port
server {
listen 80 default_server;
server_name _;
return 444; # drop connection (no response)
}
# handles all requests that don't match another server_name
# _ is an invalid hostname, used as catch-all convention
# useful to block requests with no matching Host headerListen Directive
listen can bind to a specific address, IPv6, UNIX socket, or set the default_server. The ssl parameter enables TLS for that listener. Multiple listen directives are allowed in one server block.
# listen on port 80
listen 80;
# IPv6
listen [::]:80;
# HTTPS (ssl flag)
listen 443 ssl;
listen [::]:443 ssl;
# specific address
listen 127.0.0.1:8080;
# default server for this port
listen 80 default_server;
# listen on UNIX socket
listen unix:/var/run/nginx.sock;Server Name Matching
Matching priority is exact > leading-wildcard > trailing-wildcard > regex. Regexes are checked in config order, so the first match wins. Increase hash bucket size if server names are long or numerous.
# Order of precedence (highest to lowest):
# 1. Exact name: example.com
# 2. Longest wildcard starting with *: *.example.com
# 3. Longest wildcard ending with *: www.*
# 4. First matching regex (in config order): ~^www\d+\.
# If nothing matches, the default_server handles it
# Tune hash sizes for many server names
http {
server_names_hash_bucket_size 64;
server_names_hash_max_size 512;
}Catch-all Server
A catch-all prevents requests with arbitrary Host headers from reaching your apps. return 301 redirects to a known host. return 444 drops the connection, useful against scanners hitting your IP directly.
# catch all requests regardless of host header
server {
listen 80 default_server;
server_name _; # invalid name, never matches real host
return 301 https://$host$request_uri;
}
# or drop the connection silently
server {
listen 80 default_server;
server_name _;
return 444;
}
# redirect unknown subdomains to main site
server {
listen 80 default_server;
return 301 https://example.com;
}Location Blocks
Location Modifiers
Modifiers change matching behavior: = (exact), ^~ (prefix, skip regex), ~ (case-sensitive regex), ~* (case-insensitive regex), none (prefix). Regex is best for file extensions; prefix for path trees.
# exact match - fastest, stops search if matched
location = /favicon.ico {
log_not_found off;
}
# prefix match with ^~ - skips regex if longest prefix matches
location ^~ /static/ {
root /var/www;
}
# case-sensitive regex
location ~ \.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
}
# case-insensitive regex
location ~* \.(jpg|jpeg|png|gif|ico)$ {
expires 30d;
}
# prefix match (no modifier)
location / {
try_files $uri $uri/ /index.php;
}Location Priority
Nginx evaluates exact (=) first, then prefix matches (longest wins), then regex in config order. ^~ makes a prefix match skip regex evaluation. Understanding this order prevents surprises with overlapping locations.
# Priority order (highest to lowest):
# 1. = exact match
# 2. ^~ prefix match (skips regex if matched)
# 3. ~ ~* regex (first match in config order)
# 4. (none) prefix match (longest wins)
location = /api {
# exact, highest priority
}
location ^~ /assets/ {
# prefix, beats regex
}
location ~ \.php$ {
# regex
}
location /api/ {
# prefix, lowest of these
}Root vs Alias
root appends the URI to its value; alias replaces the matched prefix. Use root when the location maps to a subdirectory of a tree. Use alias when the URI path differs from the filesystem path. alias with regex needs $1 capture.
# root: appends the full URI to the path
# request /img/cat.png -> /var/www/img/cat.png
location /img/ {
root /var/www;
}
# alias: replaces the matched location part
# request /img/cat.png -> /data/images/cat.png
location /img/ {
alias /data/images/;
}
# alias with regex requires capturing the path
location ~ ^/img/(.*)$ {
alias /data/images/$1;
}try_files
try_files checks each argument in order; the last is the fallback (no file check). $uri checks the file, $uri/ checks the directory. The fallback can be a URI, =code, or @named. Essential for SPAs and CMS routing.
# try files in order, fall back to last option
location / {
try_files $uri $uri/ /index.html;
}
# serve index.php if file/dir not found (pretty URLs)
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# return 404 if nothing matches
location / {
try_files $uri =404;
}
# named location as fallback
location / {
try_files $uri $uri/ @drupal;
}Named Locations
Named locations (@name) are internal-only — never matched by a request URI directly. They're targets for try_files, error_page, and rewrite. Great for sharing a config block (like a fallback handler) across multiple locations.
# named locations start with @, can't be reached externally
# used as try_files fallback or rewrite target
location / {
try_files $uri $uri/ @app;
}
location @app {
fastcgi_pass unix:/run/php.sock;
# ... fastcgi config
}
# useful for organizing common error logic
location @error_page {
return 500 "Internal Server Error";
}
# can also be used with error_page
error_page 404 @fallback;
location @fallback {
proxy_pass http://backend;
}Nested Locations
Locations can nest. Inner locations inherit and can override directives from outer ones. Useful for special-casing a path within a broader prefix (e.g., larger body size for uploads, or serving static files from one sub-path).
location /api {
# /api/users, /api/posts handled here
proxy_pass http://backend;
location /api/upload {
# override for upload endpoint
client_max_body_size 50M;
proxy_pass http://upload_backend;
}
location ~ ^/api/static/(.*)$ {
# regex inside prefix location
root /var/www;
}
}Reverse Proxy
Basic Proxy Pass
proxy_pass forwards requests to an upstream server. A trailing slash on proxy_pass causes the matched location prefix to be replaced. Without a trailing slash, the original URI is passed through unchanged.
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://localhost:3000;
}
}
# proxy_pass with URI replacement (trailing slash matters)
location /api/ {
proxy_pass http://backend/; # /api/foo -> /foo
}
# without trailing slash, URI is preserved
location /api/ {
proxy_pass http://backend; # /api/foo -> /api/foo
}Proxy Headers
By default nginx doesn't pass the client IP or original host. Setting X-Real-IP and X-Forwarded-For lets the backend identify the real client. X-Forwarded-Proto tells it the original scheme, critical for HTTPS redirect logic.
location / {
proxy_pass http://backend;
# preserve original host
proxy_set_header Host $host;
# real client IP (not nginx's)
proxy_set_header X-Real-IP $remote_addr;
# forwarded-for chain
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# scheme (http or https)
proxy_set_header X-Forwarded-Proto $scheme;
# original host
proxy_set_header X-Forwarded-Host $host;
}Upstream Proxy
An upstream block defines a named server pool that proxy_pass can reference. This enables load balancing, health checks, and keepalive connections. Upstream variables are useful for logging which backend handled each request.
# define a named pool of backend servers
upstream backend {
server 10.0.0.1:8080;
server 10.0.0.2:8080;
server 10.0.0.3:8080;
}
server {
listen 80;
location / {
proxy_pass http://backend;
}
}
# useful upstream variables:
# $upstream_addr, $upstream_status,
# $upstream_response_time, $upstream_cache_statusProxy Timeouts
proxy_connect_timeout is the TCP handshake limit; read/send timeouts apply to idle periods during data transfer. Increase read_timeout for slow endpoints (exports, AI inference). upstream keepalive reuses connections to reduce latency.
location / {
proxy_pass http://backend;
# connect timeout (TCP handshake to upstream)
proxy_connect_timeout 5s;
# time between reads from upstream (response)
proxy_read_timeout 60s;
# time between writes to upstream (request body)
proxy_send_timeout 60s;
# for slow backends (large uploads/downloads)
proxy_read_timeout 300s;
}
# keepalive connections to upstream
upstream backend {
server 10.0.0.1:8080;
keepalive 32;
}Proxy Buffering
Buffering stores the upstream response in memory/disk before sending to the client — efficient for slow clients but breaks streaming. Turn it off for SSE, WebSocket, or chunked streaming. buffer_size is the first part (headers); buffers hold the rest.
location / {
proxy_pass http://backend;
# enable buffering (default on)
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
# busy buffers during streaming
proxy_busy_buffers_size 16k;
# disable for streaming/SSE
proxy_buffering off;
proxy_cache off;
}
# Server-Sent Events or streaming responses
location /events {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
}WebSocket Upgrade
WebSocket needs HTTP/1.1 plus Upgrade and Connection headers. The map block converts the Upgrade header into the correct Connection value (upgrade vs close). Long proxy_read_timeout prevents idle WebSockets from being closed.
# Map the Upgrade header to a Connection value
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# keepalive for long-lived connections
proxy_read_timeout 3600s;
}
}Load Balancing
Upstream Block
An upstream block defines backend servers. Default load balancing is round-robin. max_fails and fail_timeout configure passive health checks: after max_fails failures within fail_timeout, the server is marked down for fail_timeout seconds.
upstream backend {
server backend1.example.com weight=5;
server backend2.example.com;
server backend3.example.com max_fails=3 fail_timeout=30s;
}
server {
location / {
proxy_pass http://backend;
}
}
# default algorithm: round-robin
# max_fails: failed attempts before marking server down
# fail_timeout: window for counting fails AND downtime durationLoad Balancing Methods
round-robin distributes evenly; least_conn sends to the least-loaded server; ip_hash keeps a client on the same server (sticky sessions via IP); hash distributes by a custom key. The consistent parameter minimizes redistribution when servers are added/removed.
# round-robin (default) - even distribution
upstream backend {
server s1.example.com;
server s2.example.com;
}
# least connections - fewest active connections
upstream backend {
least_conn;
server s1.example.com;
server s2.example.com;
}
# ip_hash - sticky by client IP (session persistence)
upstream backend {
ip_hash;
server s1.example.com;
server s2.example.com;
}
# generic hash (key-based, e.g., by URI or header)
upstream backend {
hash $request_uri consistent;
server s1.example.com;
server s2.example.com;
}Weighted Round Robin
weight controls traffic distribution proportionally. Default weight is 1. Set higher weights for more powerful servers so they handle a larger share. Weights work with round-robin, least_conn, and ip_hash.
upstream backend {
server s1.example.com weight=3; # 3x traffic
server s2.example.com weight=2; # 2x traffic
server s3.example.com weight=1; # 1x traffic (default)
}
# total weight = 6
# s1 gets 3/6 = 50%, s2 gets 2/6 = 33%, s3 gets 1/6 = 17%
# useful when servers have different capacity
# e.g., a 4-core server gets weight=4, a 2-core gets weight=2Health Checks
Open-source nginx uses passive health checks (max_fails/fail_timeout) based on real request failures. NGINX Plus adds active health checks that periodically poll a URI. A server must pass 'passes' consecutive checks to be marked healthy again.
upstream backend {
server s1.example.com max_fails=3 fail_timeout=30s;
server s2.example.com max_fails=3 fail_timeout=30s;
# passive health check (default behavior):
# 3 failures within 30s marks the server down
# it stays down for fail_timeout (30s) before retry
}
# NGINX Plus active health check
upstream backend {
server s1.example.com;
server s2.example.com;
health_check interval=10s fails=3 passes=2;
health_check uri=/health;
}Session Persistence
ip_hash provides sticky sessions by client IP but breaks if the IP changes (mobile networks). The hash directive with consistent offers minimal key redistribution when the pool changes. sticky cookie (Plus) sets a cookie to pin a client to a server.
# ip_hash: routes by client IP (uses first 3 IPv4 octets)
upstream backend {
ip_hash;
server s1.example.com:8080;
server s2.example.com:8080;
}
# sticky cookie (NGINX Plus only)
upstream backend {
sticky cookie srv_id expires=1h domain=.example.com path=/;
server s1.example.com;
server s2.example.com;
}
# hash with consistent parameter for minimal redistribution
upstream backend {
hash $http_x_session_id consistent;
server s1.example.com;
server s2.example.com;
}Backup Servers
Marked with the backup parameter, these servers only receive traffic when all primary servers fail. Common pattern: primaries serve the app; a backup serves a static maintenance page. Backup servers are excluded from normal load balancing.
upstream backend {
server s1.example.com;
server s2.example.com;
# used only when all primaries are down
server s3.example.com backup;
server s4.example.com backup;
}
# backup servers receive traffic ONLY when all
# non-backup servers are unavailable
# useful for a maintenance page or DR fallback
upstream backend {
server primary.example.com;
server standby.example.com backup;
}SSL/TLS Configuration
Basic HTTPS Server
The ssl parameter on listen enables TLS. ssl_certificate points to the cert chain (server cert + intermediates); ssl_certificate_key points to the private key. Always use fullchain.pem so clients can verify the chain.
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/certs/example.com.crt;
ssl_certificate_key /etc/ssl/private/example.com.key;
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}SSL Certificate Setup
Let's Encrypt stores certs under /etc/letsencrypt/live/<domain>/. fullchain.pem includes intermediates; privkey.pem is the private key. nginx supports multiple cert types per server, selecting based on client capability.
# Let's Encrypt paths
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# multiple cert types (RSA + ECDSA) on one server
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/example.com.rsa.crt;
ssl_certificate_key /etc/ssl/example.com.rsa.key;
ssl_certificate /etc/ssl/example.com.ecdsa.crt;
ssl_certificate_key /etc/ssl/example.com.ecdsa.key;
}HTTP to HTTPS Redirect
The cleanest pattern is a dedicated port-80 server that redirects. $host preserves the original hostname. Using if ($scheme) in a combined block works but the separate-server approach is preferred and avoids 'if is evil' pitfalls.
# redirect all HTTP traffic to HTTPS
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# single server block handling both
server {
listen 80;
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/example.com.crt;
ssl_certificate_key /etc/ssl/example.com.key;
if ($scheme != "https") {
return 301 https://$host$request_uri;
}
}SSL Protocols & Ciphers
Disable old protocols (SSLv3, TLSv1.0/1.1). TLS 1.3 is fastest and most secure. Use the Mozilla SSL Configuration Generator for a tested profile. shared:SSL cache speeds up session resumption across workers.
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/example.com.crt;
ssl_certificate_key /etc/ssl/example.com.key;
# modern configuration (Mozilla SSL Config Generator)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# session settings
ssl_session_tickets off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
}HSTS
HSTS tells browsers to always use HTTPS for the domain, preventing SSL-stripping attacks. max-age is in seconds (31536000 = 1 year). includeSubDomains covers all subdomains. preload submits the site to a built-in browser list — essentially irreversible, use carefully.
server {
listen 443 ssl;
server_name example.com;
# HSTS: force HTTPS for 1 year (include subdomains)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# preload list (be very careful — requires permanent HTTPS)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
# Preload commits your domain to a browser-baked HTTPS-only list.
# Only enable preload after verifying all subdomains work on HTTPS.OCSP Stapling
OCSP stapling lets nginx fetch and serve the certificate's revocation status, saving the client a round-trip to the OCSP responder. ssl_trusted_certificate is needed to verify the OCSP response. A resolver is required for nginx to reach the OCSP server.
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/example.com/privkey.pem;
ssl_stapling on;
ssl_stapling_verify on;
# trust chain for verification
ssl_trusted_certificate /etc/ssl/example.com/chain.pem;
# DNS resolver needed for OCSP requests
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
}Static File Serving
Serve Static Files
root defines the document root; nginx maps the request URI to a file path. try_files checks the file, then directory, then returns 404. Setting root at server level lets all locations inherit it, but you can override per-location.
server {
listen 80;
server_name example.com;
root /var/www/html;
location / {
try_files $uri $uri/ =404;
}
}
# root set at server level is inherited by all locations
# request /images/logo.png -> /var/www/html/images/logo.png
# request /css/style.css -> /var/www/html/css/style.cssIndex Directive
index lists files to try when the request is for a directory. The first existing file is served. If none exist and autoindex is off, nginx returns 403. Order matters — list preferred files first.
server {
listen 80;
root /var/www/html;
# try these files in order when request targets a directory
index index.html index.htm index.php;
location / {
try_files $uri $uri/ =404;
}
}
# request / -> tries /var/www/html/index.html, then index.htm
# request /about/ -> tries /var/www/html/about/index.html
# if no index file found and autoindex off -> 403 Forbiddentry_files for SPA
SPAs use client-side routing, so unknown paths must serve index.html. try_files $uri $uri/ /index.html does exactly that. Cache hashed assets for a year with immutable, since their content-addressed filenames guarantee a new URL when content changes.
# Single Page Application (React, Vue, Angular)
server {
listen 80;
root /var/www/spa;
location / {
# serve static files, fall back to index.html for client routing
try_files $uri $uri/ /index.html;
}
# cache static assets aggressively (filename usually hashed)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}Autoindex (Directory Listing)
autoindex generates an HTML directory listing. Useful for file-download sites. Turn off exact_size for readability and on localtime for correct timestamps. Never enable on directories with sensitive files — always pair with access control.
location /files/ {
autoindex on;
autoindex_exact_size off; # human-readable sizes (KB, MB)
autoindex_localtime on; # show local time instead of UTC
root /var/www;
}
# /files/ -> directory listing
# /files/doc.pdf -> downloads the file
# /files/sub/ -> listing of subdirectory
# disable for security on sensitive directories
location /private/ {
autoindex off;
deny all;
}Expire Headers
expires sets the Cache-Control max-age. immutable tells browsers the file never changes (skip revalidation). For hashed assets (file.abc123.js), 1y + immutable is ideal. HTML should always revalidate so users get updates immediately.
# cache static assets for a year
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# never cache HTML (always revalidate)
location ~* \.html$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# short cache for API responses
location /api/ {
add_header Cache-Control "no-cache";
}Disable Logging for Static
Static asset and favicon/robots requests clutter access logs. access_log off stops logging; log_not_found off suppresses 404 log entries. This keeps logs focused on meaningful traffic and reduces disk I/O on busy sites.
# don't log requests for static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
access_log off;
log_not_found off;
expires 1y;
}
# don't log favicon requests (often 404 from browsers)
location = /favicon.ico {
log_not_found off;
access_log off;
}
# don't log robots.txt
location = /robots.txt {
log_not_found off;
access_log off;
}Gzip Compression
Enable Gzip
gzip compresses responses, reducing bandwidth. gzip_vary adds 'Vary: Accept-Encoding' for caches. gzip_proxied any compresses for proxied requests. comp_level 6 balances CPU and size. min_length 256 skips tiny responses (overhead exceeds gain).
http {
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
}Gzip Types
gzip_types lists MIME types to compress. text/html is always compressed by default. Don't compress binary formats that are already compressed (images, video, archives) — you waste CPU for no size reduction and can even enlarge the output.
http {
gzip on;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/xml+rss
application/rss+xml
application/atom+xml
image/svg+xml
font/ttf
font/otf
application/vnd.ms-fontobject;
# text/html is always compressed (implicitly, cannot disable)
# DON'T compress already-compressed: jpg, png, gif, mp4, zip, gz
}Gzip Compression Level
comp_level trades CPU for compression. 6 is the sweet spot — 9 uses far more CPU for <2% smaller output. min_length avoids compressing tiny responses where the gzip header overhead exceeds the savings. gzip_disable excludes legacy IE6 which has broken gzip support.
http {
gzip on;
gzip_comp_level 6; # 1-9, higher = more compression, more CPU
# level 1: fast, low compression
# level 6: good balance (recommended default)
# level 9: slow, marginal size gain
# minimum response size to compress (bytes)
gzip_min_length 256;
# disable gzip for old browsers
gzip_disable "MSIE [1-6]\.";
}Gzip Static
gzip_static serves pre-built .gz files, eliminating runtime compression CPU cost. Build step creates .gz files alongside originals. nginx serves .gz to clients that send Accept-Encoding: gzip, the original to others. Ideal for static asset pipelines.
# serve pre-compressed .gz files (no runtime compression cost)
# requires ngx_http_gzip_static_module
location / {
gzip_static on;
# on: serve .gz if it exists
# always: serve .gz even without Accept-Encoding header
root /var/www/html;
}
# pre-compress files at build time:
# find /var/www/html -type f ( -name "*.css" -o -name "*.js" \
# -o -name "*.html" ) -exec gzip -k -9 {} \;
# creates file.css.gz, file.js.gz alongside the originalsBrotli
Brotli outperforms gzip for text content, especially at higher compression levels. It requires the third-party ngx_brotli module. brotli_static serves pre-built .br files. Modern browsers advertise 'br' in Accept-Encoding, so nginx picks the best available.
# requires ngx_brotli module (not built-in)
http {
brotli on;
brotli_comp_level 6;
brotli_types
text/plain
text/css
application/javascript
application/json
image/svg+xml;
# serve pre-compressed .br files
brotli_static on;
}
# Brotli typically 10-20% smaller than gzip for text
# supported by all modern browsers (not IE)
# nginx can serve both .br and .gz; picks based on Accept-EncodingGzip Proxied
By default nginx doesn't compress proxied responses (to avoid double-compressing already-compressed upstreams). gzip_proxied any enables it for all proxied requests. gzip_vary ensures caches store separate versions for compressed/uncompressed clients.
http {
gzip on;
gzip_proxied any;
# gzip_proxied controls compression for proxied requests:
# off: never compress proxied requests
# expired: compress if response has Expires header
# no-cache: compress if Cache-Control: no-cache
# no-store: compress if Cache-Control: no-store
# private: compress if Cache-Control: private
# no_last_modified: compress if no Last-Modified header
# no_etag: compress if no ETag header
# auth: compress if Authorization header present
# any: compress all proxied requests
gzip_vary on; # add "Vary: Accept-Encoding" header
}Caching
Proxy Cache
proxy_cache stores upstream responses on disk. keys_zone defines a shared memory zone for cache keys (10m ~= 80,000 keys). max_size limits disk usage; inactive removes entries not accessed in that time. use_temp_path=off writes directly to the cache for speed.
http {
# define cache zone on disk
proxy_cache_path /var/cache/nginx levels=1:2
keys_zone=api_cache:10m
max_size=1g
inactive=60m
use_temp_path=off;
server {
location /api/ {
proxy_pass http://backend;
proxy_cache api_cache;
proxy_cache_valid 200 10m;
proxy_cache_valid 404 1m;
}
}
}Cache Zone
levels=1:2 creates a two-level directory tree from the cache key's MD5 hash to avoid too many files in one directory. Separate zones per content type let you tune max_size and inactive independently — static cached longer, API cached briefly.
http {
proxy_cache_path /var/cache/nginx
levels=1:2 # directory hierarchy depth
keys_zone=my_cache:10m # zone name + shared memory size
max_size=1g # max disk size for cache
inactive=60m # remove if not accessed in 60min
use_temp_path=off; # write directly (faster, no temp copy)
# multiple zones for different content types
proxy_cache_path /var/cache/static levels=1:2
keys_zone=static:10m max_size=500m inactive=24h;
proxy_cache_path /var/cache/api levels=1:2
keys_zone=api:10m max_size=200m inactive=10m;
}Cache Key
The cache key determines cache uniqueness. The default key may cause collisions across virtual hosts — include $host. For per-user content, include a user identifier. proxy_cache_bypass skips the cache (still stores); proxy_no_cache doesn't store the response.
location / {
proxy_pass http://backend;
proxy_cache api_cache;
# default key: $scheme$proxy_host$request_uri
proxy_cache_key "$scheme$request_method$host$request_uri";
# include user-specific data to avoid cross-user leaks
proxy_cache_key "$scheme$host$request_uri$http_x_user_id";
# bypass cache for authenticated users
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;
}Cache Validity
proxy_cache_valid sets TTLs per status code. 0s or omitting a status prevents caching it. proxy_cache_lock serializes concurrent cache misses for the same key — only one request hits the upstream; the rest wait for the cached response.
location / {
proxy_pass http://backend;
proxy_cache api_cache;
# cache by response status code
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_valid 500 502 503 504 0s; # don't cache errors
# fallback for any other status
proxy_cache_valid any 1m;
# cache lock: collapse concurrent misses into one upstream call
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
}Bypass Cache
proxy_cache_bypass skips reading the cache (goes to upstream) but may still store the response. proxy_no_cache skips storing. The X-Cache-Status header is invaluable for debugging — HIT means served from cache, MISS means fetched from upstream.
location / {
proxy_pass http://backend;
proxy_cache api_cache;
# bypass cache (always go to upstream)
proxy_cache_bypass $http_authorization $arg_nocache;
# don't store in cache
proxy_no_cache $http_authorization;
# add header showing cache status for debugging
add_header X-Cache-Status $upstream_cache_status;
}
# $upstream_cache_status values:
# MISS, BYPASS, EXPIRED, STALE, UPDATING, REVALIDATED, HITBrowser Caching
Browser caching reduces repeat-visit latency. immutable skips revalidation entirely (only safe for content-addressed filenames). no-cache means revalidate every time (still allows conditional GET). no-store means never store. Pair with ETags for efficient 304 responses.
# cache static assets in the browser
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML - revalidate always
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
etag on;
}
# API responses - never cache
location /api/ {
add_header Cache-Control "no-store";
}
# public: any cache (browser, CDN) may store
# private: only the browser may store
# immutable: file never changes, skip revalidation
# no-store: never cacheURL Rewrite
return Directive
return is the simplest way to redirect or respond. It stops processing immediately. Use 301 for permanent, 302 for temporary redirects. You can return a status code with a body (200, 418) or a redirect to a URL. Prefer return over rewrite for redirects.
# simplest redirect/rewrite - return stops processing
server {
location /old {
return 301 /new;
}
location /external {
return 301 https://example.com$request_uri;
}
location /api {
return 200 "OK\n";
}
location /teapot {
return 418 "I'm a teapot";
}
}
# return is preferred over rewrite for simple redirectsrewrite Directive
rewrite matches the URI against a regex and replaces it. last re-evaluates location matching with the new URI (like starting over). break stops rewriting but stays in the current location. redirect/permanent send a redirect to the browser.
# rewrite regex replacement [flag]
server {
# rewrite /old/* to /new/* preserving the captured part
rewrite ^/old/(.*)$ /new/$1 permanent;
# rewrite old blog URLs to new structure
rewrite ^/blog/(\d{4})/(\d{2})/(.*)$ /articles/$1/$2/$3 last;
# capture with named groups
rewrite ^/download/(.*)$ /assets/$1 last;
}
# flags:
# last: re-search locations with the new URI
# break: stop rewrite, continue in current location
# redirect: 302 temporary redirect
# permanent: 301 permanent redirectRewrite Flags
last vs break is the most confusing part. last re-runs the location search (useful when the rewrite targets a different location). break stops rewriting but stays (useful when the rewrite just adjusts an internal path). redirect/permanent change the browser's URL.
location / {
# last: restart location matching with new URI
rewrite ^/api/(.*)$ /backend/$1 last;
# break: stop processing rewrites, stay in current location
rewrite ^/static/(.*)$ /assets/$1 break;
# redirect: 302 temporary redirect (browser URL changes)
rewrite ^/old/(.*)$ /new/$1 redirect;
# permanent: 301 permanent redirect (browser URL changes)
rewrite ^/old/(.*)$ /new/$1 permanent;
}
# avoid rewrite inside if blocks - use location + try_files insteadNamed Captures
Named captures (?<name>pattern) create variables ($name) that are clearer than positional $1, $2. They work in both location regex and rewrite. The captured values are available in proxy_pass, fastcgi_param, add_header, and log_format within that location.
# named captures in regex locations
location ~ ^/users/(?<username>[a-z]+)/?$ {
proxy_pass http://backend/users/$username;
}
# named captures in rewrite
location / {
rewrite ^/articles/(?<year>\d{4})/(?<slug>[a-z-]+)/?$
/blog?year=$year&slug=$slug last;
}
# named captures become variables: $year, $slug, $username
# accessible in proxy_pass, fastcgi_pass, headers, etc.
# safer than $1, $2 positional captures for readabilitymap Directive
map builds a lookup table from one variable to another, evaluated lazily. It's far more efficient and readable than chains of if statements. The default branch handles unmatched values. Regex matches (prefixed with ~) are checked in order.
# map creates a new variable based on another variable's value
map $http_host $backend {
default "http://default.backend";
"a.com" "http://a.backend";
"b.com" "http://b.backend";
"~^.*\.app\.com$" "http://app.backend";
}
server {
server_name a.com b.com;
location / {
proxy_pass $backend;
}
}
# map is evaluated lazily and cached per request
# more efficient than if/else chainsif Directive (Caution)
if has surprising behavior inside location blocks — it can rewrite configs unpredictably. The only universally safe uses are return and rewrite. For file existence checks, use try_files. For complex logic, use map or separate location blocks. See the wiki: 'If Is Evil'.
# "if is evil" — use sparingly! Only safe for:
# return / rewrite, and with these checks:
# -f, -d, -e, -x (file/dir/exists/executable)
# =, !=, ~, ~* (string/regex comparison)
location / {
if ($request_method = POST) {
return 405;
}
if ($http_user_agent ~* "bot") {
return 403;
}
# BAD: don't do this inside if
# if (-f $request_filename) { ... }
# use try_files instead
try_files $uri $uri/ /index.html;
}Redirect
Permanent Redirect (301)
301 means 'moved permanently' — browsers and search engines cache it, so use it only for truly permanent changes. Test redirects with 302 first to avoid locking in a mistake. 301 passes link equity (SEO) to the new URL.
# 301 - permanent redirect (cached by browsers indefinitely)
server {
listen 80;
server_name old-domain.com;
# redirect entire site to a new domain
return 301 https://new-domain.com$request_uri;
}
location /old-page {
return 301 /new-page;
}
# 301 caches indefinitely in browsers — test with 302 first!
# Search engines treat 301 as a permanent move and update indexes.Temporary Redirect (302)
302 changes POST to GET on redirect (historical behavior). 307 and 308 preserve the method — important for resubmitting POSTs. 303 explicitly converts to GET (useful after form submission to prevent double-submit on refresh). Choose based on whether the method should be preserved.
# 302 - temporary redirect (not cached by default)
location /maintenance {
return 302 /maintenance.html;
}
# 307 - temporary, preserves HTTP method (POST stays POST)
location /api/v1 {
return 307 /api/v2;
}
# 308 - permanent, preserves HTTP method
location /api/v1 {
return 308 /api/v2;
}
# 303 - see other (always uses GET for the new request)
location /form-submit {
return 303 /success;
}Redirect www to non-www
Pick one canonical form (www or non-www) and redirect the other. Two server blocks is the cleanest pattern: the non-canonical one only redirects. $request_uri preserves the path and query string. Apply the same logic for HTTPS redirects.
# www to non-www
server {
listen 80;
server_name www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 80;
server_name example.com;
# ... main site config
}
# non-www to www (reverse direction)
server {
listen 80;
server_name example.com;
return 301 https://www.example.com$request_uri;
}Redirect HTTP to HTTPS
$host preserves the original hostname, so the redirect works for any server_name. $request_uri preserves the path and query. For non-standard ports, append :port to $host. A dedicated port-80 server block is cleaner than per-location if checks.
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# with a non-standard HTTPS port
server {
listen 8080;
return 301 https://$host:8443$request_uri;
}
# redirect only a specific path
location /secure {
if ($scheme = http) {
return 301 https://$host$request_uri;
}
}Redirect to Another Domain
For domain migrations, $request_uri preserves the full path and query so links don't break. Use rewrite with a capture when the path structure changes. 301 is correct for permanent migrations — it transfers SEO ranking to the new domain.
# domain migration — preserve all paths
server {
listen 80;
server_name old.com www.old.com;
return 301 https://new.com$request_uri;
}
# preserve path and query string
location /docs/ {
return 301 https://docs.new.com$request_uri;
}
# with path rewrite using capture groups
location /old-blog/ {
rewrite ^/old-blog/(.*)$ https://blog.new.com/$1 permanent;
}Path-based Redirect
Path redirects are useful for URL cleanups (removing trailing slashes, file extensions) and content moves. Regex locations with captures ($1) enable flexible path rewriting. Query-parameter-based redirects use $arg_name to read individual query params.