Reverse proxy and TLS

Put an application behind nginx, forward the headers it needs, and terminate HTTPS in one place.

Proxying an application

location / {
    proxy_pass http://127.0.0.1:3000;      # no trailing slash: URI passes through
    proxy_http_version 1.1;
    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;
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        "upgrade";   # websockets
    proxy_read_timeout 60s;
}

Without the forwarded headers the application sees nginx as the client, believes every request arrived over plain HTTP, and generates wrong absolute URLs or redirect loops.

⚠️
The trailing slash on proxy_pass changes the path. proxy_pass http://app:3000/; strips the matched location prefix, while proxy_pass http://app:3000; forwards the URI unchanged. That one character causes more 404s than anything else in nginx.

Upstream pools

upstream app {
    least_conn;                       # or round-robin, or ip_hash
    server 10.0.0.11:3000 max_fails=3 fail_timeout=15s;
    server 10.0.0.12:3000 max_fails=3 fail_timeout=15s;
    keepalive 32;                     # reuse upstream connections
}

location /api/ {
    proxy_pass            http://app;
    proxy_next_upstream   error timeout http_502 http_503;
}
StrategyBehaviour
round-robin (default)One request per server in turn
least_connSend to the server with the fewest active connections
ip_hashSame client IP to the same server; useful for legacy session state
weight=nBias traffic towards a stronger node

A failed server is taken out of rotation for fail_timeout after max_fails errors, then tried again. proxy_next_upstream decides which errors are worth retrying on another node.

TLS termination

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

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

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

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;

    add_header Strict-Transport-Security "max-age=31536000" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header X-Forwarded-Proto $scheme;   # tell the app it is HTTPS
    }
}
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run      # certificates last 90 days; automate renewal
  • Terminating TLS here means one place to manage certificates, while internal traffic on a private network stays plain HTTP.
  • Set X-Forwarded-Proto so the application knows the original scheme; otherwise it mixes HTTPS pages with HTTP asset links.
  • Add the HSTS header only once you are confident HTTPS works everywhere, because browsers will refuse plain HTTP for the stated period.

FAQ

Why does my app redirect in an infinite loop?
It is forcing HTTPS while X-Forwarded-Proto still says http, so it redirects again. Set the header in nginx and mark the proxy as trusted in the application's configuration.
How do I renew certificates?
certbot renew is idempotent. Schedule it twice a day (systemd timer or cron), reload nginx afterwards, and always try --dry-run first.

Server blocks and static files Caching, compression and traps

Last refreshed 2026-09-18.