Skip to content

Nginx チートシート

High-performance HTTP server and reverse proxy.

01

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.

nginx
# /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 nginx

Directives & 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.

nginx
# 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.

nginx
# 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 nginx

Main 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.

nginx
# 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.

nginx
# 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.

nginx
# 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 stop
02

Server 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.

nginx
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.

nginx
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.

nginx
# 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 header

Listen 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.

nginx
# 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.

nginx
# 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.

nginx
# 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;
}
03

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.

nginx
# 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.

nginx
# 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.

nginx
# 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.

nginx
# 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.

nginx
# 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).

nginx
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;
    }
}
04

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.

nginx
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.

nginx
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.

nginx
# 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_status

Proxy 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.

nginx
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.

nginx
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.

nginx
# 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;
    }
}
05

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.

nginx
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 duration

Load 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.

nginx
# 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.

nginx
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=2

Health 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.

nginx
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.

nginx
# 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.

nginx
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;
}
06

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.

nginx
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.

nginx
# 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.

nginx
# 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.

nginx
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.

nginx
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.

nginx
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;
}
07

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.

nginx
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.css

Index 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.

nginx
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 Forbidden

try_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.

nginx
# 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.

nginx
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.

nginx
# 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.

nginx
# 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;
}
08

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).

nginx
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.

nginx
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.

nginx
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.

nginx
# 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 originals

Brotli

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.

nginx
# 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-Encoding

Gzip 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.

nginx
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
}
09

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.

nginx
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.

nginx
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.

nginx
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.

nginx
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.

nginx
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, HIT

Browser 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.

nginx
# 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 cache
10

URL 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.

nginx
# 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 redirects

rewrite 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.

nginx
# 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 redirect

Rewrite 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.

nginx
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 instead

Named 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.

nginx
# 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 readability

map 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.

nginx
# 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 chains

if 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'.

nginx
# "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;
}
11

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.

nginx
# 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.

nginx
# 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.

nginx
# 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.

nginx
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.

nginx
# 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.

nginx
# redirect specific paths
location /promo/2023 {
    return 301 /promo/2024;
}

# remove trailing slash
location ~ ^/(.*)/$ {
    return 301 /$1;
}

# remove .html extension
location ~ ^/(.*)\.html$ {
    return 301 /$1;
}

# redirect based on query parameter
location /download {
    if ($arg_version = "1") {
        return 301 /download?v=2;
    }
}
12

FastCGI (PHP-FPM)

PHP-FPM Basic Setup

fastcgi_pass sends PHP requests to PHP-FPM via a UNIX socket (faster, local only) or TCP port. include fastcgi_params sets standard CGI variables. SCRIPT_FILENAME tells PHP which file to execute — combining the document root with the script path.

nginx
server {
    listen 80;
    server_name example.com;
    root /var/www/laravel/public;
    index index.php;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
        # or: fastcgi_pass 127.0.0.1:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

FastCGI Params

fastcgi_params sets environment variables passed to PHP. SCRIPT_FILENAME is the most critical — without it, PHP can't find the script. Custom params (APP_ENV, DB_HOST) let you inject config without modifying the app. PATH_INFO enables clean URLs in some frameworks.

nginx
location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;

    # standard CGI parameters
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;

    # custom application parameters
    fastcgi_param APP_ENV production;
    fastcgi_param DB_HOST db.internal;

    # flag for HTTPS (so PHP knows the original scheme)
    fastcgi_param HTTPS $https_flag;
}

FastCGI Cache

fastcgi_cache stores PHP responses, dramatically reducing load for cacheable pages. fastcgi_cache_use_stale serves a stale cached response if the backend errors or times out — improves resilience. Be careful not to cache per-user or authenticated content.

nginx
http {
    fastcgi_cache_path /var/cache/nginx/php
        levels=1:2
        keys_zone=phpcache:100m
        max_size=1g
        inactive=60m;

    server {
        location ~ \.php$ {
            fastcgi_pass unix:/run/php/php-fpm.sock;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

            fastcgi_cache phpcache;
            fastcgi_cache_valid 200 10m;
            fastcgi_cache_key $scheme$host$request_uri;
            fastcgi_cache_use_stale error timeout invalid_header;
        }
    }
}

FastCGI Buffers

Buffers hold the PHP response before sending to the client. Small buffers cause nginx to write to disk (slow) for large responses. If you see 'upstream sent too big header' errors, increase fastcgi_buffer_size. For big API responses, use larger/fewer buffers.

nginx
location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;

    # buffer settings
    fastcgi_buffering on;
    fastcgi_buffer_size 16k;
    fastcgi_buffers 16 16k;
    fastcgi_busy_buffers_size 32k;

    # for large responses (e.g., PHP generating big JSON)
    fastcgi_buffers 64 16k;
    fastcgi_buffer_size 32k;

    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

FastCGI Timeouts

fastcgi_read_timeout is how long nginx waits for PHP to produce output. Long-running scripts (data imports, report generation, AI calls) need higher values. Make sure PHP's max_execution_time is at least as high, or PHP will time out first.

nginx
location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;

    fastcgi_connect_timeout 5s;
    fastcgi_read_timeout 300s;    # for long-running scripts
    fastcgi_send_timeout 300s;

    # increase for very slow PHP scripts (imports, exports, reports)
    fastcgi_read_timeout 600s;
}

# also adjust max_execution_time in php.ini to match
# otherwise PHP kills the script before nginx times out

try_files with PHP

try_files $uri $uri/ /index.php?$args is the standard pattern for PHP frameworks (WordPress, Laravel, Drupal). It serves real files/dirs, otherwise routes to index.php with query string. $args preserves the original query parameters.

nginx
# WordPress-style pretty URLs
server {
    root /var/www/wordpress;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # deny access to sensitive files
    location ~* /wp-config.php {
        deny all;
    }
}
13

WebSocket Proxy

WebSocket Proxy

WebSocket requires HTTP/1.1 plus Upgrade and Connection headers. The map block converts the client's Upgrade header into the correct Connection value: 'upgrade' for WebSocket, 'close' for normal HTTP. The long read_timeout keeps idle WebSocket connections alive.

nginx
# Map the Upgrade header to a Connection value
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name ws.example.com;

    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_read_timeout 3600s;  # 1 hour
        proxy_send_timeout 3600s;
    }
}

Upgrade Headers

HTTP/1.1 is mandatory for WebSocket (HTTP/2 handles multiplexing differently). Connection can be hardcoded to 'upgrade', but the map variable approach also handles non-WebSocket requests on the same location. Pass Host so the backend can validate the Origin header.

nginx
location /ws {
    proxy_pass http://backend;

    # HTTP/1.1 is required for the Upgrade mechanism
    proxy_http_version 1.1;

    # pass the Upgrade header through
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    # preserve host for origin checks on the backend
    proxy_set_header Host $host;

    # real client IP
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

WebSocket Timeout

The default proxy_read_timeout (60s) closes idle WebSocket connections. Set it high (86400s = 24h) to keep connections alive between messages. proxy_buffering off ensures messages reach the client immediately rather than being batched.

nginx
location /ws {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    # WebSocket connections are long-lived
    # default 60s would close them during idle periods
    proxy_read_timeout 86400s;  # 24 hours
    proxy_send_timeout 86400s;

    # disable buffering for real-time messages
    proxy_buffering off;
}

Socket.io Proxy

Socket.io starts with HTTP long-polling, then upgrades to WebSocket if available. Both transports go through the same proxy_pass, so one location handles everything. Passing X-Forwarded-Proto lets socket.io know it's behind HTTPS so it constructs correct URLs.

nginx
server {
    listen 80;
    server_name socket.example.com;

    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;

        proxy_read_timeout 86400s;

        # socket.io needs these for correct client IP and scheme
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# socket.io uses both polling (HTTP) and WebSocket transports
# both are handled by the proxy_pass above

Multiple WebSocket Paths

Different WebSocket services can be routed by path to different backends. Each location needs the same upgrade headers. The map block is defined once at http level and shared. Tune read_timeout per service — chat may be active often; notifications may idle for hours.

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;

    # chat application
    location /ws/chat/ {
        proxy_pass http://chat_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
    }

    # notifications service
    location /ws/notify/ {
        proxy_pass http://notify_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 86400s;
    }
}

WSS (Secure WebSocket)

WSS is WebSocket over TLS. nginx terminates the TLS (handles the certificate) and proxies a plain WebSocket to the backend. The backend doesn't need TLS configuration. This is the standard production setup — browsers require WSS for HTTPS pages (mixed-content rules).

nginx
server {
    listen 443 ssl;
    server_name ws.example.com;

    ssl_certificate     /etc/ssl/ws.example.com.crt;
    ssl_certificate_key /etc/ssl/ws.example.com.key;

    location / {
        proxy_pass http://backend:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 86400s;
    }
}

# browser connects with wss://ws.example.com
# nginx terminates TLS, proxies plain WebSocket to the backend
14

Security Headers

X-Frame-Options

X-Frame-Options prevents your site from being embedded in iframes, blocking clickjacking attacks. SAMEORIGIN allows your own pages to frame themselves. The modern replacement is CSP frame-ancestors, which is more flexible. always ensures the header is sent on error responses too.

nginx
# prevent clickjacking (your site embedded in an iframe)
add_header X-Frame-Options "SAMEORIGIN" always;

# options:
# DENY          - no framing at all
# SAMEORIGIN    - only same-site can frame
# ALLOW-FROM    - deprecated, use CSP frame-ancestors instead

# modern equivalent via Content-Security-Policy
add_header Content-Security-Policy "frame-ancestors 'self'" always;

X-Content-Type-Options

nosniff tells browsers to trust the Content-Type header and not sniff the content. Without it, a browser might execute an uploaded image as HTML if it sniffs HTML content. This is a low-effort, high-value header — always enable it.

nginx
# prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;

# forces the browser to respect the declared Content-Type
# prevents XSS via uploaded files (e.g., an image containing HTML
# being executed as a script)

# related: block Adobe Flash / PDF cross-domain policies
add_header X-Permitted-Cross-Domain-Policies "none" always;

Content-Security-Policy

CSP is the most powerful defense against XSS — it whitelists allowed sources for scripts, styles, images, etc. Start with Report-Only to find violations without breaking anything, then enforce. 'self' means same origin; 'unsafe-inline' allows inline scripts/styles (needed by many apps).

nginx
# strict CSP — restricts where resources can load from
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" always;

# report-only mode (logs violations without blocking)
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always;

X-XSS-Protection

X-XSS-Protection enabled the browser's built-in reflected-XSS filter. Modern browsers have removed this feature in favor of CSP. mode=block prevented sanitization (which could introduce new vulnerabilities) and just blocked the page. Harmless to include for legacy browsers, but CSP is the real defense.

nginx
# legacy XSS auditor (older browsers)
add_header X-XSS-Protection "1; mode=block" always;

# values:
# 0              - disable the filter
# 1              - enable (sanitize)
# 1; mode=block  - enable and block the page entirely

# NOTE: deprecated in modern browsers (Chrome, Edge, Safari removed it)
# CSP is the modern replacement, but this doesn't hurt to include
# for legacy browser support

Referrer-Policy

Referrer-Policy controls how much URL information is leaked via the Referer header when users click links. strict-origin-when-cross-origin (the browser default) sends the full URL for same-origin requests but only the origin for cross-origin, and nothing on downgrade to HTTP.

nginx
# control what the Referer header contains
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# options:
# no-referrer                  - never send Referer
# no-referrer-when-downgrade   - default; no Referer on HTTPS->HTTP
# same-origin                  - only same-site
# origin                       - send only scheme+host
# strict-origin                - origin, but not on downgrade
# origin-when-cross-origin     - full for same, origin for cross
# strict-origin-when-cross-origin - recommended default
# unsafe-url                   - send full URL (not recommended)

Permissions-Policy

Permissions-Policy lets you disable browser features (camera, microphone, geolocation, etc.) even if the page requests them. An empty () disables the feature entirely. This limits the blast radius of an XSS — a compromised script can't turn on the camera if the policy forbids it.

nginx
# control which browser features the page can use
# (formerly Feature-Policy)
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=()" always;

# allow only for same origin
add_header Permissions-Policy "geolocation=(self), microphone=(self)" always;

# allow for specific origins
add_header Permissions-Policy "camera=(self https://trusted.com)" always;
15

Access Control

Allow/Deny by IP

allow/deny rules are evaluated in order; the first match wins. The final deny all blocks anything not explicitly allowed. This is useful for locking admin panels to corporate IPs. Rules can use CIDR ranges or single IPs, and support both IPv4 and IPv6.

nginx
location /admin {
    allow 192.168.1.0/24;     # internal network
    allow 10.0.0.0/8;         # VPN range
    allow 203.0.113.5;        # specific IP
    deny all;

    proxy_pass http://backend;
}

# rules are checked in order — first match wins
# if no rule matches, access is allowed by default

Deny All

deny all blocks everyone; pair with allow for a whitelist. The reverse — deny specific IPs, allow all — is a blacklist. Whitelists are more secure (default deny). Without an explicit allow/deny, access is allowed, so always end with deny all for restricted areas.

nginx
# deny all access to a location
location /private {
    deny all;
    return 403;
}

# deny specific IPs, allow everyone else
location / {
    deny 192.168.1.100;        # block one IP
    deny 203.0.113.0/24;       # block a range
    allow all;
}

# deny a range, allow the rest
location /api {
    deny 10.0.0.0/8;
    allow all;
    proxy_pass http://backend;
}

Allow Subnet

Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) are non-routable on the internet, so allowing them restricts access to internal networks. For IPv6, ::1 is localhost. Combine with a VPN for secure remote access to internal tools.

nginx
# allow only local/private networks
location /internal {
    allow 192.168.0.0/16;     # private class B
    allow 172.16.0.0/12;      # private class B range
    allow 10.0.0.0/8;         # private class A
    deny all;

    root /var/www/internal;
}

# IPv6
location / {
    allow 2001:db8::/32;      # example IPv6 range
    allow ::1;                # IPv6 localhost
    deny all;
}

Geo Module

geo is optimized for IP lookups using a radix tree — far faster than a long allow/deny list. It sets a variable (here $allowed_ip) based on the client IP. The 'ranges' form supports start-end IP ranges. Use the variable in if() or map for flexible access control.

nginx
# map IPs to values (binary search, very fast)
geo $allowed_ip {
    default        0;
    192.168.0.0/24 1;
    10.0.0.0/8     1;
    203.0.113.5    1;
}

server {
    location /admin {
        if ($allowed_ip = 0) {
            return 403;
        }
        proxy_pass http://backend;
    }
}

# geo with IP ranges (uses 'ranges' directive)
geo $country_code {
    ranges;
    default                 ZZ;
    10.0.0.0-10.255.255.255 US;
}

Limit by IP (limit_req)

limit_req_zone defines a rate per IP using $binary_remote_addr (compact). limit_req applies it. burst allows short spikes (queue up to 20). nodelay serves the burst immediately rather than throttling — good for APIs. Without nodelay, excess requests are delayed, not rejected.

nginx
http {
    # define rate limit zone (10 requests/sec per IP)
    limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=mylimit burst=20 nodelay;
            proxy_pass http://backend;
        }
    }
}

# $binary_remote_addr uses binary form (more memory-efficient)
# 10m zone stores ~160,000 unique IP addresses
# rate=10r/s: 10 requests per second per IP
# burst=20: allow 20 requests to queue
# nodelay: serve the burst immediately (no artificial delay)

Country Blocking (GeoIP)

The GeoIP module maps client IPs to country codes using a MaxMind database. Combined with map, you can block or allow entire countries. Note: MaxMind's free GeoLite2 requires the third-party maxminddb module (the legacy GeoIP module uses the older .dat format). Accuracy isn't 100% — use for coarse filtering only.

nginx
# requires the GeoIP module and a MaxMind database
geoip_country /usr/share/GeoIP/GeoIP.dat;

map $geoip_country_code $allowed_country {
    default yes;
    CN no;
    RU no;
    KP no;
}

server {
    location / {
        if ($allowed_country = no) {
            return 403;
        }
        proxy_pass http://backend;
    }
}

# GeoIP2 (maxminddb module) for the newer GeoLite2 format
# maxminddb /etc/nginx/geoip2/GeoLite2-Country.mmdb;
# $geoip2_country_code source=$remote_addr country iso_code;
16

Authentication

Basic Auth Setup

auth_basic triggers the browser's native username/password dialog. The realm string ('Admin Area') is shown to the user. auth_basic_user_file points to a htpasswd file. Use auth_basic off to disable auth in a sub-location that should be public.

nginx
location /admin {
    auth_basic "Admin Area";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://backend;
}

location /public {
    # disable auth inherited from parent location
    auth_basic off;
}

# create users with htpasswd (apache2-utils / httpd-tools):
# htpasswd -c /etc/nginx/.htpasswd user1   (create file)
# htpasswd /etc/nginx/.htpasswd user2      (append, no -c)

htpasswd File

The htpasswd file stores username:hash pairs. bcrypt (-B) is the strongest hash; MD5 (-m, default) is acceptable; SHA1 (-s) is weak; PLAIN is insecure. Restrict file permissions so only nginx can read it — the file contains password hashes that must not leak.

nginx
# /etc/nginx/.htpasswd format:
# user1:$apr1$qHR...$hashedpassword
# user2:{PLAIN}plaintext       (insecure!)
# user3:{SHA}base64sha1hash

# generate with htpasswd:
# -c  create new file (overwrites!)
# -B  bcrypt (most secure, recommended)
# -m  MD5 (default, APR1)
# -s  SHA1

# bcrypt (recommended)
# htpasswd -B /etc/nginx/.htpasswd user1

# protect the file permissions
chown root:nginx /etc/nginx/.htpasswd
chmod 640 /etc/nginx/.htpasswd

Auth for Specific Location

auth_basic is inherited by nested locations. To make a sub-path public, set auth_basic off. This is useful when most of /admin requires auth but /admin/health (a health check endpoint) should be accessible without credentials for monitoring tools.

nginx
server {
    # no auth at server level

    location /admin {
        auth_basic "Admin";
        auth_basic_user_file /etc/nginx/.htpasswd;
    }

    location /admin/api {
        # inherits auth from /admin
        proxy_pass http://admin_api;
    }

    location /admin/public {
        # explicitly disable inherited auth
        auth_basic off;
        proxy_pass http://public_api;
    }
}

Allow/Deny with Auth

satisfy any means passing either the IP check OR authentication grants access — convenient for internal users (no password) while still requiring a password for external users. satisfy all requires both — the most restrictive option, useful for highly sensitive areas.

nginx
# satisfy: ALL (default) or ANY
location / {
    # allow corporate IPs without password
    # require password from outside the corporate network
    satisfy any;

    allow 192.168.0.0/16;
    deny all;

    auth_basic "Restricted";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://backend;
}

# satisfy all: must pass BOTH IP check AND auth
# satisfy any: pass EITHER IP check OR auth

Auth Request Module

auth_request sends an internal subrequest to an auth service before the main request. The service validates the token (JWT, session cookie, etc.) and returns 2xx (allow) or 401/403 (deny). internal prevents external access to /auth. This decouples auth from your app.

nginx
# delegate authentication to a subrequest (OAuth, JWT, etc.)
location / {
    auth_request /auth;
    proxy_pass http://backend;
}

location = /auth {
    internal;                    # can't be called directly
    proxy_pass http://auth-service/validate;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
}

# auth-service returns:
# 2xx -> allow (continue to backend)
# 401/403 -> deny (return to client)
# other -> error

JWT Auth

JWT validation can be done in-process with njs (nginx JavaScript module) for low latency, or delegated to an external service via auth_request. njs runs a subset of JavaScript inside nginx — fast but limited. The auth_request approach is more flexible and language-agnostic.

nginx
# requires the njs (nginx JavaScript) module
js_import auth.js;

location /api/ {
    js_content auth.validateJwt;
    proxy_pass http://backend;
}

# auth.js defines the validateJwt function:
# function validateJwt(r) {
#     var token = r.headersIn.Authorization;
#     if (!token || !verifyJwt(token)) {
#         r.return(401);
#         return;
#     }
# }

# alternative: use auth_request with an external JWT validator service
# more portable than njs, but adds a network hop per request
17

Logging

Access Log

access_log records every request. log_format defines what fields to capture. The default 'combined' format is Apache-compatible. For high-traffic sites, buffer+flush reduces disk I/O by batching writes. syslog sends logs to a central server for aggregation.

nginx
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;
    # or use the built-in 'combined' format
    access_log /var/log/nginx/access.log combined;
}

# disable access log
access_log off;

# log to syslog
access_log syslog:server=localhost,facility=local7 main;

# buffer writes (reduce I/O)
access_log /var/log/nginx/access.log main buffer=32k flush=5s;

Error Log

error_log captures nginx errors and upstream issues. The level filters severity — warn is a good default. debug is very verbose (use only for troubleshooting) and requires a debug build. memory: buffers logs in RAM — useful for diagnosing startup issues that disk logging can't capture.

nginx
# error log location and severity level
error_log /var/log/nginx/error.log warn;

# levels (low to high severity):
# debug, info, notice, warn, error, crit, alert, emerg

# debug requires nginx built with --with-debug
error_log /var/log/nginx/error.log debug;

# log to memory (for debugging, lost on restart)
error_log memory:32m debug;

# disable (NOT recommended — hides real errors)
error_log /dev/null crit;

Log Formats

Custom log formats capture what matters to you. request_time is total processing time; upstream_response_time isolates the backend. escape=json produces valid JSON for log aggregators (ELK, Loki, Datadog). Without escape=json, special characters in URIs could break JSON parsing.

nginx
http {
    # combined (Apache-compatible, the default)
    log_format combined '$remote_addr - $remote_user [$time_local] '
                        '"$request" $status $body_bytes_sent '
                        '"$http_referer" "$http_user_agent"';

    # main with upstream timing
    log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    'rt=$request_time uct="$upstream_connect_time" '
                    'urt="$upstream_response_time"';

    # JSON format for log aggregation (ELK, Loki, etc.)
    log_format json escape=json
        '{"time":"$time_iso8601",'
        '"remote":"$remote_addr",'
        '"method":"$request_method",'
        '"uri":"$request_uri",'
        '"status":$status,'
        '"bytes":$body_bytes_sent,'
        '"ua":"$http_user_agent"}';
}

Conditional Logging

The if= parameter conditionally logs based on a variable. map $status lets you log only errors (4xx/5xx), reducing noise and disk usage on high-traffic sites. The variable must be 0/empty to skip logging, non-zero to log.

nginx
http {
    # don't log successful 2xx/3xx, only errors
    map $status $loggable {
        ~^[23]  0;
        default 1;
    }

    server {
        access_log /var/log/nginx/access.log main if=$loggable;
    }
}

# skip logging for static assets
map $request_uri $log_skip {
    default                    1;
    ~*\.(jpg|png|gif|css|js)$  0;
}
access_log /var/log/nginx/access.log main if=$log_skip;

Log Rotation

logrotate renames and compresses old logs. postrotate sends USR1 to nginx, which reopens its log files — without this, nginx keeps writing to the renamed file. sharedscripts runs postrotate once (not per file). delaycompress compresses on the second rotation, so the most recent log is uncompressed.

nginx
# logrotate config: /etc/logrotate.d/nginx
/var/log/nginx/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    create 0640 www-data adm
    sharedscripts
    postrotate
        if [ -f /var/run/nginx.pid ]; then
            kill -USR1 $(cat /var/run/nginx.pid)
        fi
    endscript
}

# USR1 signal tells nginx to reopen log files
# without dropping connections
# nginx -s reopen does the same thing

Disable Logging

Disabling access_log for health checks and static assets reduces log noise and disk I/O significantly. log_not_found off suppresses 404 log entries (useful for favicon.ico). Always keep error_log enabled at least at 'error' level — silent failures are hard to diagnose.

nginx
# disable access log entirely
access_log off;

# disable for a specific location
location /health {
    access_log off;
    return 200 "ok\n";
}

# disable for static assets
location ~* \.(jpg|png|gif|ico|css|js)$ {
    access_log off;
    log_not_found off;
}

# only log errors (reduce error log verbosity)
error_log /var/log/nginx/error.log error;
18

Rate Limiting

limit_req_zone

limit_req_zone defines a rate limit keyed by a variable (usually client IP). The zone size (10m) determines how many unique keys can be tracked. rate is the allowed request frequency. Define zones in http context, apply them in location context with limit_req.

nginx
http {
    # 10 requests per second per IP
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    # 1 request per second per IP (for login)
    limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;

    # 100 requests per minute per IP
    limit_req_zone $binary_remote_addr zone=general:10m rate=100r/m;
}

# $binary_remote_addr uses binary form (more memory-efficient than text)
# 10m zone stores ~160,000 unique IP addresses
# rate can be per second (r/s) or per minute (r/m)

limit_req

limit_req applies a zone to a location. burst allows short spikes (queue up to N excess requests). nodelay serves the burst at full speed rather than spacing them out — good for APIs where latency matters. Without burst, every request exceeding the rate is rejected immediately.

nginx
http {
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=api burst=20 nodelay;
            proxy_pass http://backend;
        }

        location /login {
            limit_req zone=api burst=5 nodelay;
            proxy_pass http://backend;
        }
    }
}

# burst: max number of requests allowed to queue
# nodelay: serve the burst immediately (no artificial throttling)
# without burst: requests exceeding the rate get 503 immediately

limit_conn

limit_conn limits concurrent connections, not request rate. Useful for preventing a single IP from opening hundreds of simultaneous downloads. Combined with limit_req (rate) it provides both burst and concurrency protection. limit_conn_zone uses the same key-based zone pattern.

nginx
http {
    # limit concurrent connections per IP
    limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;

    # limit total connections per server name
    limit_conn_zone $server_name zone=conn_per_server:10m;

    server {
        limit_conn conn_per_ip 10;
        limit_conn conn_per_server 1000;

        location /download {
            limit_conn conn_per_ip 1;  # one download at a time per IP
            proxy_pass http://backend;
        }
    }
}

Burst Queue

burst is the queue size for excess requests. nodelay serves the entire burst immediately (good for APIs). delay=N serves N immediately and throttles the rest to the configured rate (smoother). Without nodelay, all excess requests are delayed to match the rate, spreading load evenly.

nginx
http {
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            # burst=20: allow 20 requests to queue
            # nodelay: serve them immediately (no artificial delay)
            limit_req zone=api burst=20 nodelay;

            # delay: first 10 immediate, next 10 delayed, rest rejected
            limit_req zone=api burst=20 delay=10;

            proxy_pass http://backend;
        }
    }
}

# without burst: requests over rate get 503 immediately
# with burst: excess requests queue (default delayed)
# nodelay: queue served at full speed (allows spikes)
# delay=N: first N immediate, rest throttled to the rate

Rate Limit by IP

Define separate zones per endpoint type with appropriate rates. API endpoints tolerate higher rates; auth endpoints (login, password reset) need strict limits to prevent brute force; uploads need very low rates to prevent abuse. burst gives a small allowance for legitimate spikes.

nginx
http {
    # different limits for different endpoints
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/s;
    limit_req_zone $binary_remote_addr zone=upload:10m rate=2r/m;

    server {
        location /api/ {
            limit_req zone=api burst=20 nodelay;
        }

        location /login {
            limit_req zone=auth burst=5 nodelay;
        }

        location /upload {
            limit_req zone=upload burst=3 nodelay;
            client_max_body_size 100M;
        }
    }
}

Custom Rate Limit Response

By default, rate-limited requests get 503 (Service Unavailable). 429 (Too Many Requests) is more semantically correct and what most APIs expect. Use error_page to return a JSON body explaining the limit. Include a Retry-After header so clients know when to back off.

nginx
http {
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_req_status 429;  # default is 503

    server {
        location /api/ {
            limit_req zone=api burst=20 nodelay;

            # custom error response
            error_page 429 = @rate_limited;
        }

        location @rate_limited {
            default_type application/json;
            return 429 '{"error":"rate_limit_exceeded","retry_after":60}';
        }
    }
}

# also available: limit_conn_status 429;
19

Performance Tuning

worker_processes

worker_processes should match CPU core count — auto does this automatically. Each worker is a single-threaded process that handles many connections via event loop. worker_cpu_affinity pins workers to specific cores, improving cache locality. Auto affinity lets the OS scheduler decide.

nginx
# main context
worker_processes auto;  # one per CPU core (recommended)

# or set an explicit count
worker_processes 4;

# each worker handles connections independently
# 'auto' uses the number of available CPU cores

# CPU affinity (pin workers to specific cores)
worker_processes auto;
worker_cpu_affinity auto;

# or manual bitmask (one mask per worker)
worker_processes 4;
worker_cpu_affinity 0001 0010 0100 1000;

worker_connections

worker_connections is per worker; total capacity is worker_processes * worker_connections. This is also limited by the OS file descriptor limit (worker_rlimit_nofile). You must raise both nginx's limit and the OS limit (limits.conf or systemd's LimitNOFILE). multi_accept grabs all pending connections at once.

nginx
events {
    # connections per worker process
    worker_connections 4096;

    # accept multiple connections at once under high load
    multi_accept on;
}

# total max connections = worker_processes * worker_connections
# but also bounded by worker_rlimit_nofile (file descriptor limit)

# main context — raise the FD limit
worker_rlimit_nofile 65535;

# also raise system limits (outside nginx):
# /etc/security/limits.conf:
#   * soft nofile 65535
#   * hard nofile 65535

keepalive_timeout

Client keepalive reuses connections for multiple requests, reducing latency. keepalive_timeout is how long an idle connection stays open. Upstream keepalive (the keepalive directive in upstream) caches connections to backends — set Connection to empty so nginx doesn't send 'close'.

nginx
http {
    # keep-alive timeout (client side)
    keepalive_timeout 65s;
    keepalive_requests 1000;  # max requests per connection

    # upstream keepalive (reuse connections to backends)
    upstream backend {
        server 10.0.0.1:8080;
        keepalive 32;  # cache of 32 idle upstream connections
    }

    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";  # clear to enable keepalive
    }
}

sendfile & tcp_nopush

sendfile uses a kernel-level zero-copy path to send files, bypassing user-space buffers — much faster for static files. tcp_nopush works with sendfile to send headers and file start in one packet. tcp_nodelay disables Nagle for low latency. aio threads offload disk I/O from the event loop.

nginx
http {
    # use kernel sendfile() for static files (zero-copy)
    sendfile on;

    # send headers + start of file in one TCP packet
    tcp_nopush on;

    # disable Nagle's algorithm (send small packets immediately)
    tcp_nodelay on;

    # for static file serving
    location /static/ {
        sendfile on;
        tcp_nopush on;
        aio threads;  # asynchronous I/O via thread pool
        root /var/www;
    }
}

# sendfile: zero-copy file transfer (kernel space, no user-space copy)
# tcp_nopush: coalesce headers + file data into one packet
# tcp_nodelay: don't wait to batch small packets (low latency)

Buffer Sizes

Tune buffers to match your traffic. client_max_body_size caps upload size (default 1M — too small for file uploads). proxy/fastcgi buffers hold upstream responses; too-small buffers spill to disk. large_client_header_buffers handle long URLs/cookies. Monitor for 'buffer overflow' errors.

nginx
http {
    # client body buffer (in-memory before writing to disk)
    client_body_buffer_size 16k;
    client_max_body_size 50m;

    # client header buffers
    client_header_buffer_size 1k;
    large_client_header_buffers 4 8k;

    # proxy buffers (responses from upstream)
    proxy_buffer_size 4k;
    proxy_buffers 8 4k;
    proxy_busy_buffers_size 16k;

    # fastcgi buffers (responses from PHP-FPM, etc.)
    fastcgi_buffer_size 4k;
    fastcgi_buffers 8 4k;
}

# if a request body exceeds client_body_buffer_size,
# nginx writes it to a temp file (slower)

Open File Cache

open_file_cache caches file descriptors and metadata, avoiding repeated open()/stat() syscalls for popular files. This significantly speeds up static file serving. valid controls how often nginx rechecks the file (catches updates). min_uses avoids caching one-off requests. Cache errors too to avoid repeated failed opens.

nginx
http {
    open_file_cache max=10000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 2;
    open_file_cache_errors on;

    server {
        root /var/www/html;

        location / {
            open_file_cache max=10000 inactive=20s;
            open_file_cache_valid 30s;
            try_files $uri $uri/ =404;
        }
    }
}

# caches file descriptors, sizes, modification times
# reduces stat() and open() syscalls for popular files
# max=10000: cache up to 10000 files
# inactive=20s: remove if not accessed in 20 seconds
# min_uses=2: only cache files accessed 2+ times
# valid=30s: re-validate cache every 30s
20

Docker Deployment

Nginx in Docker

The official nginx image serves /usr/share/nginx/html by default and reads config from /etc/nginx. Mount your config and content as volumes. :ro (read-only) prevents the container from modifying host files. nginx -s reload picks up config changes without recreating the container.

nginx
# run the official nginx image
docker run -d --name nginx \
    -p 80:80 -p 443:443 \
    -v /etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro \
    -v /var/www/html:/usr/share/nginx/html:ro \
    -v /etc/nginx/conf.d:/etc/nginx/conf.d:ro \
    nginx:latest

# interactive shell into the running container
docker exec -it nginx sh

# reload config without restarting the container
docker exec nginx nginx -s reload

# view logs
docker logs nginx
docker logs -f nginx

Docker Compose

Docker Compose orchestrates nginx with its backends. expose makes a port available to linked services (not to the host). depends_on ensures backend starts first. restart: unless-stopped survives reboots but respects manual stops. Mount logs with write access (no :ro) so nginx can write them.

nginx
version: '3.8'
services:
  nginx:
    image: nginx:latest
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./conf.d:/etc/nginx/conf.d:ro
      - ./html:/usr/share/nginx/html:ro
      - ./certs:/etc/nginx/certs:ro
      - ./logs:/var/log/nginx
    depends_on:
      - backend
    restart: unless-stopped

  backend:
    image: myapp:latest
    expose:
      - "3000"

Custom Nginx Image

Baking config into the image makes deployments immutable and reproducible — no runtime volume mounts needed. The alpine variant keeps the image tiny (~7MB base). This is ideal for Kubernetes or CI/CD where you want a self-contained artifact. Rebuild to update config.

nginx
# Dockerfile
FROM nginx:alpine

# copy custom config
COPY nginx.conf /etc/nginx/nginx.conf
COPY conf.d/ /etc/nginx/conf.d/

# copy static files
COPY html/ /usr/share/nginx/html/

# copy SSL certificates
COPY certs/ /etc/nginx/certs/

# build and run
# docker build -t mynginx .
# docker run -d -p 80:80 mynginx

# alpine is ~7MB; the image stays small
# bake config in for immutable deployments

Volume Mounts

Volume mounts let you iterate on config without rebuilding the image — ideal for development. Mounting a directory is robust; mounting a single file can break if the host file doesn't exist (Docker creates a directory instead). Named volumes persist across container recreations and are managed by Docker.

nginx
# mount config and content as volumes (dev workflow)
docker run -d --name nginx \
    -p 80:80 \
    -v $(pwd)/nginx.conf:/etc/nginx/nginx.conf:ro \
    -v $(pwd)/conf.d:/etc/nginx/conf.d:ro \
    -v $(pwd)/html:/usr/share/nginx/html:ro \
    -v $(pwd)/logs:/var/log/nginx \
    nginx:alpine

# read-only mounts (:ro) for security
# logs need write access (no :ro)
# use named volumes for persistent data:
#   -v nginx_logs:/var/log/nginx

# IMPORTANT: mounting a single file (nginx.conf) requires
# the file to exist on the host, or Docker creates a directory

Reverse Proxy in Docker

In Docker Compose, services resolve each other by service name (app1, app2). nginx's upstream can reference these names directly — no need for IP addresses. This makes nginx a powerful load balancer for containerized apps. Use the default Compose network, or define a custom one for isolation.

nginx
# nginx as reverse proxy for other containers
version: '3.8'
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - app1
      - app2
    restart: unless-stopped

  app1:
    image: myapp:latest

  app2:
    image: myapp:latest

# nginx.conf:
# upstream apps {
#     server app1:3000;
#     server app2:3000;
# }
# server {
#     listen 80;
#     location / { proxy_pass http://apps; }
# }

Health Check

Health checks let Docker (and orchestrators like Swarm) know if nginx is actually serving, not just running. wget --spider checks the HTTP status without downloading the body. start_period delays the first check so nginx can boot. In Kubernetes, use a readiness/liveness probe instead of the Docker HEALTHCHECK.

nginx
# Dockerfile with a health check
FROM nginx:alpine

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1

COPY nginx.conf /etc/nginx/nginx.conf

# docker-compose health check
version: '3.8'
services:
  nginx:
    image: nginx:alpine
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 5s

# start_period gives nginx time to boot before checking
# a failed health check marks the container unhealthy

Was this helpful?