Nginx and Reverse Proxy Configuration: SSL Termination, Load Balancing, and Caching

Nginx as the Front Door

Nginx sits in front of more web applications than any other reverse proxy. Apache's still around, Caddy's gaining ground for simpler setups, and Envoy dominates in service mesh architectures — but for the straightforward "terminate TLS, load balance across backends, cache what you can" pattern, Nginx is hard to beat.

This isn't a beginner's guide to server blocks. I'm assuming you can serve a static site. Let's talk about the configuration patterns that matter in production.

TLS Termination Done Right

TLS termination at the reverse proxy means your backend services handle plain HTTP. Less CPU overhead on app servers, simpler certificate management, and one place to update cipher configurations.

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

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256;
    ssl_prefer_server_ciphers off;

    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 8.8.8.8 8.8.4.4 valid=300s;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

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

Key decisions in that config:

  • TLS 1.2 minimum. — TLS 1.0 and 1.1 are deprecated. Some compliance standards still require 1.2 support, so don't go TLS 1.3-only yet unless you've checked your client base.
  • OCSP stapling — avoids the browser making a separate request to the CA to check certificate revocation. It's a free latency improvement — enable it everywhere.
  • Session tickets off. — They compromise forward secrecy unless you rotate the ticket key, which most setups don't do properly. Shared session cache is the safer option.
  • HSTS with preload — tells browsers to always use HTTPS. The preload directive gets your domain into browser preload lists, but make sure you're committed — removing a domain from the list takes months.

Certificate Automation with Certbot

Manual certificate renewal is a ticking time bomb. Certbot with a cron job handles this:

# /etc/cron.d/certbot-renew
0 3 * * * root certbot renew --quiet --deploy-hook "systemctl reload nginx"

The --deploy-hook only fires when a certificate actually gets renewed, not on every cron run. Nginx needs a reload (not restart) to pick up the new cert — reload is graceful and doesn't drop existing connections.

Upstream Load Balancing

Nginx supports several load balancing methods. The default round-robin works fine for stateless services:

upstream backend {
    server 10.0.1.10:8080 weight=3;
    server 10.0.1.11:8080 weight=2;
    server 10.0.1.12:8080 weight=1;
    server 10.0.1.13:8080 backup;

    keepalive 32;
}

The weight parameter lets you send more traffic to beefier servers. The backup server only receives traffic when all primary servers are down. And keepalive 32 maintains a pool of persistent connections to each backend — without this, Nginx opens and closes a TCP connection for every proxied request, which adds ~1ms of latency each time.

For session-sticky applications (which you should try to avoid, but sometimes can't), use ip_hash or the sticky directive from the commercial Nginx Plus. The open-source ip_hash has a known problem: clients behind the same NAT all hash to the same backend, creating hotspots.

Health Checks

Nginx's open-source version only does passive health checks — it marks a backend as down after max_fails consecutive failures:

upstream backend {
    server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
}

This means it takes 3 real user requests to detect a dead backend. Active health checks (probing a /health endpoint periodically) require Nginx Plus or a third-party module. If you need active checks on the free tier, consider putting HAProxy in front or using the nginx_upstream_check_module.

Proxy Caching

Nginx can cache backend responses, reducing load on your application servers dramatically:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:10m
                 max_size=1g inactive=60m use_temp_path=off;

server {
    location /api/ {
        proxy_pass http://backend;
        proxy_cache app_cache;
        proxy_cache_valid 200 10m;
        proxy_cache_valid 404 1m;
        proxy_cache_use_stale error timeout updating http_500 http_502;
        proxy_cache_lock on;

        add_header X-Cache-Status $upstream_cache_status;
    }
}

The proxy_cache_use_stale directive is invaluable. When a backend is down or slow, Nginx serves the stale cached response instead of returning an error. Your users see slightly outdated data instead of a 502 — usually a much better trade-off.

proxy_cache_lock on prevents the thundering herd problem. When a cached entry expires and 100 requests arrive simultaneously, only one goes to the backend. The other 99 wait for that one response to populate the cache. Without this, all 100 hit your backend at once.

Rate Limiting

Nginx's rate limiting uses a leaky bucket algorithm:

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=api_limit burst=20 nodelay;
        limit_req_status 429;

        proxy_pass http://backend;
    }
}

The burst=20 allows short spikes of up to 20 requests before throttling kicks in. nodelay means burst requests are processed immediately rather than being spaced out — without it, burst requests get queued and responded to at the configured rate, adding latency.

For API rate limiting, you might want to key on an API key header instead of IP address:

map $http_x_api_key $api_key_limit {
    default $binary_remote_addr;
    "~."    $http_x_api_key;
}
limit_req_zone $api_key_limit zone=api_limit:10m rate=100r/s;

Common Misconfigurations

A few things I keep seeing in production configs that cause problems:

  • Missing proxy_set_header Host: Without it, the backend sees the upstream address instead of the original hostname. This breaks virtual hosting and many frameworks' URL generation.
  • Buffer sizes too small: Default proxy_buffer_size is 4k or 8k. If your backend sends headers larger than that (common with big cookies or auth tokens), you get 502 errors. Set proxy_buffer_size 16k.
  • No client_max_body_size: Defaults to 1MB. File uploads will silently fail with a 413 error.
  • Using if blocks inside location — Nginx's if is famously problematic. The Nginx wiki literally has a page called "If Is Evil." Use map and try_files instead whenever possible.