Config File Literacy: Nginx
- Config file structure
- Main context — global settings
- events block
- http block
- server block — virtual hosts
- location blocks
- Reverse proxy directives
- TLS directives
- Logging
- include — splitting config across files
- Annotated full config
- Testing config before applying
- try_files for SPA / static routing
- server_tokens and security headers
- set_real_ip_from — real client IP
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;
}
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:
$remote_addr— client IP (or proxy IP if behind a load balancer)$request— HTTP method, URI, protocol:GET /path HTTP/1.1$status— HTTP response code$body_bytes_sent— response size in bytes$http_x_forwarded_for— original client IP if set by upstream proxy$upstream_response_time— how long the backend took to respond
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.