webmasterhelper

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.