Servers · 11 minute read
nginx configuration recipes
Configuration blocks that solve the problems a scan usually finds. Test every change with nginx -t before reloading — and read the caveat at the end, because a passing test does not mean a safe config.
Canonical host and HTTPS, in one hop
The common mistake is chaining: http → https → www → path, which is three round trips. Handle every non-canonical case in one redirect.
# Everything that is not https://example.com gets exactly one 301.
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}
server {
listen 80;
listen [::]:80;
server_name example.com;
location /.well-known/acme-challenge/ { root /var/www/html; }
location / { return 301 https://example.com$request_uri; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
# ... root, locations, headers
}
Note the www block still needs a certificate covering www.example.com. A redirect happens after the TLS handshake, so an uncovered name fails before it can redirect.
Compression
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_comp_level 5;
gzip_proxied any;
gzip_types
text/css text/plain text/xml text/javascript
application/javascript application/json application/xml
application/rss+xml application/atom+xml
image/svg+xml font/ttf font/otf;
Brotli needs the ngx_brotli module. With it, pre-compress build output at deploy time and serve it statically — brotli_static on; — so the CPU cost is paid once rather than per request.
Static assets with correct caching
location ~* "\.[0-9a-f]{8,}\.(css|js|woff2|avif|webp|png|jpg|svg)$" {
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
try_files $uri =404;
}
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp|avif|woff2)$ {
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
try_files $uri =404;
}
Reverse proxy to an application
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# Without these the app sees nginx, not the visitor.
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;
# WebSocket upgrade; harmless when unused.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_buffering on;
}
Define the upgrade map once at http level:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Rate limiting
# http level
limit_req_zone $binary_remote_addr zone=general:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
# server / location level
location / {
limit_req zone=general burst=60 nodelay;
}
location /login {
limit_req zone=login burst=3;
limit_req_status 429;
}
Behind a CDN, $binary_remote_addr is the edge, so every visitor shares one bucket. Configure set_real_ip_from with the CDN ranges and real_ip_header first, or the limit is meaningless.
Honest 404s and a custom page
error_page 404 /404.html;
location = /404.html {
internal; # not reachable directly
add_header Cache-Control "no-store" always;
}
location / {
try_files $uri $uri/ =404; # NOT /index.html, which creates soft 404s
}
For a single-page application you do need the /index.html fallback, which means the framework router must render a real 404 route — and ideally the server should return a 404 status for paths you know do not exist.
Logging that analytics can use
log_format vhost_combined '$host $remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'$request_time';
access_log /var/log/nginx/access.log vhost_combined;
Adding $host is what lets one log file be attributed per site, and $request_time turns the access log into a latency record you already have.