Nginx Basics

Page 15 — Web server and reverse proxy. Server blocks, config testing, and troubleshooting.

What Nginx is

Nginx is a web server and reverse proxy. It can:

What a server block is

A server block is Nginx's configuration unit for one site or virtual host. It defines:

Main files

/etc/nginx/nginx.conf
/etc/nginx/conf.d/
/etc/nginx/sites-enabled/   # on some distros

Basic reverse proxy server block

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

What this means:

HTTPS / TLS server block

To serve HTTPS, add a separate server block listening on port 443 with the TLS certificate and key. The HTTP block on port 80 can redirect to HTTPS or be removed.

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;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;
    }
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

What this does:

Tip: Always run nginx -t after adding or changing a server block. If the cert file does not exist or the path is wrong, the test will catch it before you break a running service.

Upstream blocks and load balancing

An upstream block defines a named group of backend servers. This decouples the proxy target from the individual server block and enables load balancing.

upstream app_backend {
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;          # round-robin by default
    server 127.0.0.1:8082 weight=3; # gets 3× more traffic
    server 192.168.1.20:8080 backup; # only used if others fail
    keepalive 32;                    # keep 32 idle connections to backends
}

server {
    listen 443 ssl;
    server_name example.com;

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";   # needed for keepalive
        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;
    }
}

Nginx's default load balancing is round-robin. For session-sticky workloads consider ip_hash (same client IP → same backend) or least_conn (fewest active connections). These are set as directives inside the upstream block.

Rate limiting

Rate limiting protects backends from abuse and sudden traffic spikes. It is implemented in two parts: a shared memory zone defined at the http level, and a limit applied inside a server or location block.

# In the http { } block (top of nginx.conf or included file)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    listen 443 ssl;
    server_name api.example.com;

    location /api/ {
        limit_req zone=api_limit burst=20 nodelay;
        # burst=20: allow up to 20 queued requests above the rate
        # nodelay: process burst immediately rather than delay
        proxy_pass http://app_backend;
    }

    location /login {
        # Stricter limit for auth endpoints
        limit_req zone=api_limit burst=5 nodelay;
        proxy_pass http://app_backend;
    }
}

The zone size (10m) stores state per IP — 10 MB holds roughly 160,000 IPs. Without nodelay, Nginx queues requests that exceed the rate and delays them; with nodelay it returns 503 immediately when the burst is full. Monitor rate-limit hits in the error log at warn level.

Logging and monitoring

Custom log formats help downstream log analysis tools (Splunk, Loki, ELK) parse structured data.

# Define a JSON-like combined format in the http { } block
log_format main_json escape=json
    '{"time":"$time_iso8601",'
    '"remote_addr":"$remote_addr",'
    '"request":"$request",'
    '"status":$status,'
    '"body_bytes":$body_bytes_sent,'
    '"referrer":"$http_referer",'
    '"upstream_addr":"$upstream_addr",'
    '"request_time":$request_time}';

server {
    access_log /var/log/nginx/access.log main_json;
    error_log  /var/log/nginx/error.log  warn;
}
# Built-in status page (enable in a restricted location)
server {
    listen 127.0.0.1:8888;
    location /nginx_status {
        stub_status;
        allow 127.0.0.1;
        deny all;
    }
}

stub_status outputs active connections, accepts, handled, and requests counts — useful for monitoring scripts and dashboards. Never expose it publicly.

Common tuning knobs

Directive Where What it does
worker_processes auto;mainOne worker per CPU core
worker_connections 1024;eventsMax simultaneous connections per worker
client_max_body_size 20m;http/server/locationMax upload size; returns 413 if exceeded
proxy_read_timeout 60s;http/server/locationHow long to wait for backend response
proxy_connect_timeout 10s;http/server/locationHow long to wait to connect to backend
gzip on;http/server/locationCompress text responses; add gzip_types
server_tokens off;http/serverHide Nginx version from error pages and headers

Useful commands

nginx -t

nginx -t

Tests config syntax. Always run this before reloading or restarting. If this reports errors, do not proceed.

nginx -T

nginx -T

Prints the full loaded config including all included files. Very useful when debugging where a setting actually comes from.

systemctl reload nginx

systemctl reload nginx

Reloads config without dropping connections. Use this for config changes instead of a full restart when possible.

journalctl

systemctl status nginx
journalctl -u nginx -n 50

Troubleshooting

WebSocket upgrade

WebSockets start life as an HTTP/1.1 request with Upgrade: websocket. Nginx will not forward the hop-by-hop Upgrade/Connection headers automatically — you need to pass them through explicitly, and you need HTTP/1.1 on the upstream leg.

# http { } level — map a persistent Connection value
# so we can use $connection_upgrade inside any location
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

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

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

        proxy_set_header Host            $host;
        proxy_set_header X-Real-IP       $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        # WebSockets can idle for a long time between frames
        proxy_read_timeout  3600s;
        proxy_send_timeout  3600s;
    }
}

The map block sends Connection: upgrade when the client is negotiating a WebSocket and Connection: close otherwise — so normal HTTP requests through the same location still behave correctly. Raise proxy_read_timeout only on the WebSocket location; leaving it at 1 hour globally masks real backend hangs.

real_ip behind a load balancer

When Nginx sits behind a Layer-4 LB, a CDN, or another reverse proxy, every request appears to come from the proxy's IP. The ngx_http_realip_module (bundled by default on most distros) rewrites $remote_addr to the real client IP based on a trusted header.

server {
    listen 443 ssl;
    server_name example.com;

    # Trust these upstream proxies — only headers from these sources
    # are honoured, preventing client-supplied spoofing.
    set_real_ip_from 10.0.0.0/8;
    set_real_ip_from 192.168.0.0/16;
    set_real_ip_from 2001:db8::/32;

    real_ip_header    X-Forwarded-For;   # or CF-Connecting-IP for Cloudflare
    real_ip_recursive on;                # walk the XFF chain, skipping trusted hops

    location / {
        proxy_pass http://app_backend;
        proxy_set_header X-Real-IP       $remote_addr;   # now the true client
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Always restrict set_real_ip_from to the CIDR(s) of your actual proxies. Without that restriction an attacker can forge X-Forwarded-For and bypass access controls, rate limits, or log-based blocklists.

OCSP stapling

OCSP stapling lets Nginx fetch a signed "this cert is still valid" response from the CA and attach it to the TLS handshake, so clients do not need to make a separate OCSP call. It speeds up TLS and protects users' privacy.

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/ssl/certs/example.com.fullchain.pem;
    ssl_certificate_key /etc/ssl/private/example.com.key;

    ssl_stapling        on;
    ssl_stapling_verify on;

    # The file below MUST be the issuing CA's intermediate chain
    # (and its root), not the server cert itself.
    ssl_trusted_certificate /etc/ssl/certs/example.com.chain.pem;

    resolver 1.1.1.1 9.9.9.9 valid=300s;
    resolver_timeout 5s;
}

Verify stapling is actually working: echo | openssl s_client -connect example.com:443 -servername example.com -status 2>/dev/null | grep -A5 'OCSP Response'. If you see no response sent, check that ssl_trusted_certificate points at the chain (not the leaf), and that Nginx can reach the CA's OCSP responder — a resolver directive is required because stapling is an outbound DNS+HTTP fetch done by the Nginx worker.

include: layout

Two layouts dominate in the wild — pick one and stick to it across your fleet so Ansible roles and runbooks can rely on path conventions.

Layout Distro default How Nginx finds it
/etc/nginx/conf.d/*.confRHEL/CentOS, upstream nginx.orginclude /etc/nginx/conf.d/*.conf; in the http { } block
/etc/nginx/sites-available/ + sites-enabled/Debian/Ubuntuinclude /etc/nginx/sites-enabled/*; — symlinks in sites-enabled/ point at real files in sites-available/
Enable/disable without editing nginx.conf: on the Debian layout, ln -s ../sites-available/example.com /etc/nginx/sites-enabled/ enables a site and rm /etc/nginx/sites-enabled/example.com disables it — both followed by nginx -t && systemctl reload nginx. On the conf.d layout the same effect is achieved by renaming a file to .conf.disabled so the glob stops matching it.