Config File Literacy: Nginx

How to read and understand nginx.conf — every block, directive, and context explained.

Config file structure

Nginx config is hierarchical. Directives live inside contexts (blocks surrounded by { }). Contexts can be nested. Directives in an outer context apply to all inner contexts unless overridden.

# Structure overview:
main context (no surrounding braces)
├── events { ... }
└── http {
    ├── upstream { ... }
    └── server {
        ├── server_name directive
        ├── root directive
        └── location / {
            └── proxy_pass directive
        }
    }
}

Main context — global settings

user nginx;                  # which OS user nginx worker processes run as
worker_processes auto;       # number of workers; auto = one per CPU core
error_log /var/log/nginx/error.log warn;   # global error log
pid /run/nginx.pid;          # PID file location

worker_processes auto — for most servers, leave this on auto. Only tune manually on hosts with many CPU-intensive connections.

events block

events {
    worker_connections 1024;   # max concurrent connections per worker
                               # total max = worker_processes × worker_connections
    use epoll;                 # I/O model (epoll is best for Linux — usually auto-detected)
    multi_accept on;           # accept multiple connections per wakeup
}

http block

The http block wraps all web server config. Directives here apply to all server blocks unless overridden.

http {
    include       /etc/nginx/mime.types;    # maps file extensions to Content-Type headers
    default_type  application/octet-stream; # fallback Content-Type

    sendfile on;            # use kernel sendfile for serving static files (faster)
    tcp_nopush on;          # send headers + start of body in one packet
    keepalive_timeout 65;   # how long to keep an idle connection open (seconds)

    gzip on;                # compress responses
    gzip_types text/plain text/css application/json application/javascript;
    gzip_min_length 256;    # only compress responses above this size

    # Include all server block files from conf.d/
    include /etc/nginx/conf.d/*.conf;
}

server block — virtual hosts

Each server block defines one virtual host. Nginx matches incoming connections to server blocks by the listen port and server_name.

server {
    listen 80;                        # which port to listen on
    listen [::]:80;                   # same but for IPv6
    server_name app.example.com;      # hostname(s) this block responds to
                                      # supports wildcards: *.example.com
                                      # can list multiple: app.example.com www.example.com

    root /var/www/app;                # filesystem root for this virtual host
    index index.html;                 # default file to serve

    access_log /var/log/nginx/app.access.log;   # per-vhost access log
    error_log  /var/log/nginx/app.error.log;    # per-vhost error log
}

If no server_name matches the incoming Host: header, nginx falls back to the first server block (or one with default_server). This is why you sometimes need to set a catch-all server block.

location blocks

Location blocks match URL paths and define how to handle them. The matching syntax:

location / { }          # prefix match — matches everything starting with /
location = /exact { }  # exact match — only /exact
location ~ \.php$ { }  # regex match (case sensitive)
location ~* \.jpg$ { } # regex match (case insensitive)

Matching priority (highest to lowest): exact match (=) → ^~ prefix (wins over regex) → first matching regex (~ / ~*, tested in config order) → longest plain prefix match.

server {
    listen 80;
    server_name app.example.com;
    root /var/www/app;

    # Exact match — fastest for the health check endpoint
    location = /health {
        return 200 "OK\n";
        add_header Content-Type text/plain;
    }

    # Serve static files directly, without going to the backend
    location /static/ {
        alias /var/www/static/;     # filesystem path to serve from
        expires 30d;                # cache-control header
        add_header Cache-Control "public";
    }

    # Everything else goes to the app backend
    location / {
        proxy_pass http://127.0.0.1:8080;
    }
}

Reverse proxy directives

When nginx forwards requests to a backend:

location / {
    proxy_pass http://127.0.0.1:8080;   # backend address
                                          # can also be a named upstream block

    # Required for HTTP/1.1 keepalive to backends (see upstream section below)
    proxy_http_version 1.1;
    proxy_set_header Connection "";       # clear the Connection header (default is "close")

    # Pass the real client info to the backend
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;  # tells backend if client used https

    # Timeouts
    proxy_connect_timeout 60s;  # how long to wait for backend to accept connection
    proxy_send_timeout    60s;  # how long to wait for backend to accept data
    proxy_read_timeout    60s;  # how long to wait for backend to send response

    # Buffer settings
    proxy_buffering on;
    proxy_buffer_size 4k;
}
HTTP/1.1 keepalive footgun: If you set keepalive in an upstream block but don't add proxy_http_version 1.1; and proxy_set_header Connection ""; in the location block, keepalive silently falls back to HTTP/1.0 and connections close after every request, defeating the purpose. Always include both lines when using upstream keepalive.

X-Forwarded-For is how the backend application knows the original client IP even though it is talking to nginx. Without this header, all requests appear to come from 127.0.0.1.

TLS directives

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/ssl/certs/app.example.com.crt;    # full chain cert
    ssl_certificate_key /etc/ssl/private/app.example.com.key;  # private key

    # Modern TLS settings
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;

    # HSTS — tells browsers to always use HTTPS
    add_header Strict-Transport-Security "max-age=63072000" always;
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

Logging

http {
    log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

    access_log /var/log/nginx/access.log main;

    # Disable access log for health checks (reduces log noise)
    server {
        location = /health {
            access_log off;
            return 200;
        }
    }
}

Common nginx log variables:

include — splitting config across files

# In nginx.conf
http {
    include /etc/nginx/conf.d/*.conf;       # all .conf files in conf.d/
    include /etc/nginx/sites-enabled/*;     # Debian convention
}

# Each virtual host gets its own file:
# /etc/nginx/conf.d/app.example.com.conf
# /etc/nginx/conf.d/api.example.com.conf

The include directive is processed at load time. If any included file has a syntax error, nginx will fail to start. Run nginx -t after editing any file.

Annotated full config

# /etc/nginx/conf.d/app.example.com.conf

# Upstream block — defines backend servers
# Allows load balancing across multiple backends
upstream app_backend {
    server 127.0.0.1:8080 weight=1;      # primary
    server 127.0.0.1:8081 weight=1;      # secondary (load balanced)
    keepalive 16;                          # keep 16 connections to backends alive
}

# HTTPS virtual host
server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/ssl/certs/app.example.com.crt;
    ssl_certificate_key /etc/ssl/private/app.example.com.key;
    ssl_protocols TLSv1.2 TLSv1.3;

    access_log /var/log/nginx/app.access.log;
    error_log  /var/log/nginx/app.error.log;

    client_max_body_size 64m;              # max upload size

    # Health check endpoint
    location = /health {
        access_log off;
        return 200 "healthy\n";
        add_header Content-Type text/plain;
    }

    # Static assets — served directly by nginx
    location /static/ {
        root /var/www;                     # serves /var/www/static/*
        expires 7d;
    }

    # API — forwarded to backend
    location /api/ {
        proxy_pass http://app_backend;
        proxy_set_header Host            $host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_read_timeout 120s;
    }

    # Everything else — forwarded to backend
    location / {
        proxy_pass http://app_backend;
        proxy_set_header Host            $host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }
}

# HTTP → HTTPS redirect
server {
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

Testing config before applying

# Syntax check — safe, no changes
nginx -t

# Syntax check with verbose output
nginx -T           # shows full config including all includes

# Reload (apply config changes without dropping connections)
systemctl reload nginx

# Restart (needed for some changes like SSL cert replacement)
systemctl restart nginx

try_files for SPA / static routing

try_files checks for files on disk in order and falls back to the last argument if none exist. It is the standard solution for single-page applications (React, Vue, Angular) where the browser always requests / for every client-side route.

# SPA routing — always serve index.html for unknown paths
location / {
    root /var/www/app;
    try_files $uri $uri/ /index.html;
    # 1. Try the exact URI as a file
    # 2. Try the URI as a directory (serves index.html inside it)
    # 3. Fall back to /index.html for all other paths
}

# Static file server with 404 fallback
location /static/ {
    root /var/www;
    try_files $uri =404;   # 404 if file not found
}

# API + SPA on same server: route /api/ to backend, everything else to SPA
location /api/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}
location / {
    root /var/www/app;
    try_files $uri $uri/ /index.html;
}

server_tokens and security headers

By default nginx reveals its version in Server: response headers and error pages. Hiding this is a basic hardening step.

# In http {} or server {} block
server_tokens off;   # removes nginx version from Server: header and error pages

Common security headers

server {
    server_tokens off;

    # Prevent clickjacking
    add_header X-Frame-Options SAMEORIGIN always;

    # Prevent MIME type sniffing
    add_header X-Content-Type-Options nosniff always;

    # Enable XSS filter (older browsers)
    add_header X-XSS-Protection "1; mode=block" always;

    # HTTPS only (HSTS) — only add after SSL is confirmed working
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Referrer policy
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

Add always to ensure headers are sent on error responses (4xx, 5xx) as well as 2xx responses.

set_real_ip_from — real client IP behind a load balancer

When nginx sits behind a load balancer (AWS ELB, Cloudflare, HAProxy) or CDN, $remote_addr shows the load balancer's IP, not the client's. The ngx_http_realip_module restores the real IP.

# In http {} block — tell nginx which IPs are trusted proxies
# Replace with your actual LB / CDN CIDR ranges

# AWS ALB
set_real_ip_from 10.0.0.0/8;

# Cloudflare (see https://www.cloudflare.com/ips/ for current list)
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
# ... (add all Cloudflare ranges)

# Which header to read the real IP from
real_ip_header X-Forwarded-For;

# Take the rightmost IP in X-Forwarded-For that is NOT in the trusted list
# (prevents IP spoofing via X-Forwarded-For header manipulation)
real_ip_recursive on;

After this config, $remote_addr and $http_x_real_ip in your logs will show the actual client IP. This is also required for rate limiting by client IP to work correctly when behind a load balancer.