Server

An Nginx Reverse Proxy With TLS, Done Properly

By Prabath Thalangama· October 24, 2026· 4 min read
#nginx#tls#security

Introduction

Putting Nginx in front of an application server (Gunicorn, Node, a Docker container) is one of the most common sysadmin tasks, and one of the easiest to do 80% right and 20% wrong — a missing X-Forwarded-Proto, no WebSocket upgrade, default timeouts that kill long requests. This is a config you can adapt with confidence, plus the reasoning behind each block.

Concepts

A reverse proxy terminates the client connection, then opens its own connection to the backend. Two things the backend needs to know that it can't see any more:

  • The real client IP — otherwise every request looks like it came from 127.0.0.1. Nginx passes it in X-Forwarded-For / X-Real-IP.
  • That the original request was HTTPS — so the app builds https:// URLs and sets Secure cookies. Passed in X-Forwarded-Proto.

The backend framework then needs to be told to trust those headers (Django's SECURE_PROXY_SSL_HEADER, Express's trust proxy, etc.) — otherwise a client can spoof them.

Hands-on

The config

# /etc/nginx/conf.d/app.conf

upstream app_backend {
    server 127.0.0.1:8000;
    keepalive 32;                 # reuse backend connections
}

# Redirect all HTTP to HTTPS.
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;

    # Let certbot answer the ACME challenge over HTTP.
    location /.well-known/acme-challenge/ { root /var/www/certbot; }

    location / { return 308 https://$host$request_uri; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_stapling on;
    ssl_stapling_verify on;

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

    client_max_body_size 25m;     # match your app's upload limit

    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 $scheme;
        proxy_set_header X-Forwarded-Host  $host;

        # WebSocket / SSE upgrade.
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        proxy_connect_timeout 5s;
        proxy_read_timeout    60s;    # raise for long-poll / streaming
        proxy_send_timeout    60s;
        proxy_buffering       on;
    }
}

Add this once, in http context (/etc/nginx/conf.d/upgrade.conf):

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Why these choices:

  • return 308 not 301 — 308 preserves the request method, so a POST to http:// isn't silently downgraded to GET.
  • proxy_set_header Host $host — without it the backend sees Host: app_backend and virtual-host routing / absolute URLs break.
  • $proxy_add_x_forwarded_for appends $remote_addr to any existing header, preserving a chain if there's a CDN in front.
  • keepalive in the upstream needs proxy_http_version 1.1 and an empty Connection header (the map handles both WebSocket and keepalive).
  • http2 on; is the modern directive; listen ... ssl http2 still works but is deprecated in Nginx 1.25+.

TLS with Let's Encrypt

sudo certbot certonly --webroot -w /var/www/certbot -d app.example.com
sudo nginx -t && sudo systemctl reload nginx

Renewal is a systemd timer installed by the certbot package:

systemctl list-timers | grep certbot
sudo certbot renew --dry-run       # confirm it will work

Add a deploy hook so Nginx picks up the new cert: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh containing #!/bin/sh and systemctl reload nginx.

Verification and troubleshooting

nginx -t                                        # syntax + config test
curl -sI http://app.example.com                 # expect 308 to https
curl -sI https://app.example.com                # expect 200 and HSTS header
curl -s https://app.example.com/whoami          # app should log your real IP

Common problems:

  • App redirects to http:// in a loop — the backend doesn't trust X-Forwarded-Proto. Enable proxy-header trust in the framework.
  • 413 Request Entity Too Large — client_max_body_size (default 1 MB) is below the upload size. Set it on both the server and the backend.
  • 504 Gateway Time-out on long requests — raise proxy_read_timeout. The default is 60 s.
  • WebSockets connect then drop after ~60 s — same timeout, plus make sure the Upgrade/Connection headers and proxy_http_version 1.1 are present.
  • SSL_ERROR / cert not trusted — you referenced cert.pem instead of fullchain.pem; clients need the intermediate. Verify the chain with the SSL/TLS checker or the Certificate Decoder.
  • Real IP is always 127.0.0.1 in logs — you're behind another proxy (CDN, cloud LB). Add set_real_ip_from <lb-cidr>; and real_ip_header X-Forwarded-For;.
  • nginx -t OK but reload does nothing — you edited a file that isn't included. Check nginx -T (capital T) for the effective merged config.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.