Nginx Basics
- What Nginx is
- What a server block is
- Main files
- Basic reverse proxy server block
- HTTPS / TLS server block
- Upstream blocks and load balancing
- Rate limiting
- Logging and monitoring
- Common tuning knobs
- Useful commands
- Troubleshooting
- WebSocket upgrade
- real_ip behind a load balancer
- OCSP stapling
- include: layout
What Nginx is
Nginx is a web server and reverse proxy. It can:
- Serve static files
- Terminate TLS
- Reverse proxy requests to backend applications
- Host multiple sites using server blocks (virtual hosts)
What a server block is
A server block is Nginx's configuration unit for one site or virtual host. It defines:
- Which hostname this config applies to
- Which port to listen on
- What to do with incoming requests
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:
- Listen on port 80
- Respond for requests to
example.com - Forward all requests to the backend app on localhost port 8080
- Pass the original host and client IP in headers to the backend
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:
- Serves HTTPS on port 443, loading the cert and key from the specified paths
- Proxies requests to the backend app on localhost port 8080
- Passes
X-Forwarded-Proto: httpsso the backend knows the original request was HTTPS - Redirects all plain HTTP traffic permanently to HTTPS
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; | main | One worker per CPU core |
worker_connections 1024; | events | Max simultaneous connections per worker |
client_max_body_size 20m; | http/server/location | Max upload size; returns 413 if exceeded |
proxy_read_timeout 60s; | http/server/location | How long to wait for backend response |
proxy_connect_timeout 10s; | http/server/location | How long to wait to connect to backend |
gzip on; | http/server/location | Compress text responses; add gzip_types |
server_tokens off; | http/server | Hide 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
- Config test first:
nginx -t - Port 80/443 listening:
ss -tulpn | grep nginx - Upstream reachable:
curl -I http://127.0.0.1:8080 - DNS resolves correctly to this server
- Cert files exist and permissions are correct
- Check logs:
journalctl -u nginx -n 50 - Full config dump:
nginx -Tto see the actual effective config
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/*.conf | RHEL/CentOS, upstream nginx.org | include /etc/nginx/conf.d/*.conf; in the http { } block |
/etc/nginx/sites-available/ + sites-enabled/ | Debian/Ubuntu | include /etc/nginx/sites-enabled/*; — symlinks in sites-enabled/ point at real files in sites-available/ |
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.