Nginx Reverse Proxy + TLS

Nginx is the workhorse we put in front of almost every web service: it terminates TLS, load-balances across upstream replicas, serves static assets, and forwards everything else to the application. In this guide we'll build a modern, secure reverse proxy configuration step by step — TLS 1.2/1.3, HSTS, OCSP stapling, WebSocket support, and the security headers that earn an A+ on observatory scans.

Real-World Scenario

SPA + API on subdomains, behind one Nginx box.

You host app.example.com (a single-page app) and api.example.com (a JSON API) on the same server. Both need real TLS certs, HTTP→HTTPS redirects, modern headers, WebSocket support for live updates, and a small upstream pool of API workers. We'll build that exact configuration by the end of this guide.

Server Blocks and proxy_pass

Nginx routes requests via server blocks. Each block matches on listen address/port and server_name. The simplest proxy looks like this:

The minimum viable reverse proxy

# /etc/nginx/conf.d/app.conf
server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

That works but it's missing the headers the upstream app needs to behave correctly behind a proxy. Let's fix that.

The Four Headers You MUST Set

Without these, the upstream app sees the request as coming from Nginx (127.0.0.1) on http://, with no idea who the real client is. This breaks logging, rate limiting, CSRF, redirects, and anything that does request.scheme or request.host.

  • Host — preserve the original hostname so the upstream's virtual hosting and absolute URL generation work.
  • X-Real-IP — the client's IP, single value.
  • X-Forwarded-For — append the client IP to any existing chain (useful when there are multiple proxies).
  • X-Forwarded-Protohttps or http so the app can build correct absolute URLs and set the secure flag on cookies.

The full proxy header block (factor into a snippet)

# /etc/nginx/snippets/proxy_headers.conf
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;

# Sensible timeouts (defaults are 60s)
proxy_connect_timeout 5s;
proxy_send_timeout    60s;
proxy_read_timeout    60s;

# HTTP/1.1 to support keepalive and WebSocket upgrades
proxy_http_version 1.1;

Then include it in any location that proxies:

location / {
    include /etc/nginx/snippets/proxy_headers.conf;
    proxy_pass http://127.0.0.1:3000;
}

Upstream Blocks & Load Balancing

To put multiple backends behind one Nginx, declare an upstream. Nginx will distribute requests across them.

Upstream patterns

# Round-robin (default) — distributes requests evenly
upstream api_backend {
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
    server 10.0.0.13:3000;
}

# Least connections — best for variable-cost requests
upstream api_backend {
    least_conn;
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
}

# IP hash — same client always lands on the same backend
# (use when your app stores session state in-process)
upstream api_backend {
    ip_hash;
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
}

# Health/backup helpers
upstream api_backend {
    server 10.0.0.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:3000;
    server 10.0.0.99:3000 backup;   # only used if others are down
}

server {
    listen 80;
    server_name api.example.com;
    location / {
        include /etc/nginx/snippets/proxy_headers.conf;
        proxy_pass http://api_backend;
    }
}

Tip: for modern stateless apps, prefer least_conn. Use ip_hash only when you genuinely have in-process session state — it interacts badly with autoscaling because adding/removing a backend reshuffles every client.

TLS Termination (Modern Config)

This is the part where many guides go wrong. The recommendations below match the Mozilla "Intermediate" profile as of mid-2020s: TLS 1.2 and 1.3 only, modern cipher list, server cipher preference disabled (TLS 1.3 ignores it anyway, and for TLS 1.2 modern clients pick fine).

Modern TLS settings (drop into a snippet)

# /etc/nginx/snippets/tls_modern.conf
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_ciphers         'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
# TLS 1.3 ignores server cipher order; leave it off so TLS 1.2 also follows client preference
ssl_prefer_server_ciphers off;

# Session resumption (perf)
ssl_session_cache   shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

# OCSP stapling — see next section
ssl_stapling         on;
ssl_stapling_verify  on;
resolver             1.1.1.1 9.9.9.9 valid=300s;
resolver_timeout     5s;

Bind it to a server block:

server {
    listen 443 ssl;
    http2 on;             # nginx 1.25+: prefer this over `listen 443 ssl http2`
    server_name app.example.com;

    include /etc/nginx/snippets/tls_modern.conf;

    # ...location blocks...
}

HTTP → HTTPS Redirect

A dedicated port-80 server block that 301-redirects everything to HTTPS. It also leaves room for the ACME challenge path that Let's Encrypt uses.

Redirect block

server {
    listen 80;
    server_name app.example.com api.example.com;

    # Let certbot's http-01 challenges through (no redirect)
    location /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    # Everything else gets redirected
    location / {
        return 301 https://$host$request_uri;
    }
}

HSTS — HTTP Strict Transport Security

HSTS tells browsers "never speak plain HTTP to this domain again, for the next N seconds." Once a browser sees this header, it will refuse to fall back to HTTP — protecting against downgrade attacks.

HSTS header (RFC 6797)

# Inside the HTTPS server block, after include of tls_modern.conf
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

Warning: once you set HSTS on a domain, browsers will lock onto HTTPS for max-age seconds. Don't roll it out until you're certain HTTPS works on every subdomain you cover with includeSubDomains. Start with a smaller max-age (e.g. 300) when testing, then ratchet up to 2 years (63072000). Only add ; preload after you've intentionally submitted to the HSTS preload list.

OCSP Stapling

OCSP (RFC 6960) is how clients check whether a cert has been revoked. By default the browser contacts the CA's OCSP responder, which is slow and leaks browsing data. With stapling, your server fetches a signed OCSP response itself and "staples" it onto the TLS handshake — faster and more private.

The directives in our tls_modern.conf handle this:

  • ssl_stapling on; — enable stapling.
  • ssl_stapling_verify on; — verify the OCSP response signature.
  • resolver 1.1.1.1 9.9.9.9 valid=300s; — Nginx needs a DNS resolver to look up the OCSP responder URL; without this, stapling silently fails.

Verify stapling is working after a reload:

echo | openssl s_client -connect app.example.com:443 -servername app.example.com -status 2>/dev/null | grep -E 'OCSP response:|Cert Status'
# You want to see: "OCSP Response Status: successful" and "Cert Status: good"

Let's Encrypt with certbot

Certbot's Nginx plugin reads your config, issues a cert via the ACME http-01 challenge, and edits the config in place to enable HTTPS. The simplest invocation:

Issue and auto-configure

sudo apt install certbot python3-certbot-nginx        # Ubuntu/Debian
# or:  sudo dnf install certbot python3-certbot-nginx  # RHEL/Rocky/Fedora

# Issue cert and let certbot edit nginx config
sudo certbot --nginx -d app.example.com -d api.example.com

# Renewal happens automatically via systemd timer (certbot.timer) on most distros.
# Force a renewal dry-run to verify the setup:
sudo certbot renew --dry-run

http-01 vs dns-01

Two ACME challenge types:

  • http-01 — Let's Encrypt connects to http://yourdomain/.well-known/acme-challenge/<token> on port 80. Requires that port 80 be reachable from the internet. Cannot issue wildcard certs.
  • dns-01 — you (or a plugin) create a TXT record under _acme-challenge.yourdomain. Works behind firewalls and is the only way to get a wildcard cert (*.example.com). Requires API access to your DNS provider.

Use http-01 by default. Reach for dns-01 when you need wildcards or your servers don't expose port 80.

WebSocket Proxying

WebSockets use an HTTP/1.1 upgrade handshake. Nginx needs explicit Upgrade and Connection headers to pass it through. The cleanest pattern uses a map so the same config also works for non-upgrading requests.

WebSocket-ready proxy

# At the http {} (or global) level — typically in nginx.conf or a conf.d/ file
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;
    include /etc/nginx/snippets/tls_modern.conf;

    location / {
        include /etc/nginx/snippets/proxy_headers.conf;
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # WebSocket connections can sit idle; bump read timeout
        proxy_read_timeout 3600s;
    }
}

Compression — gzip and brotli

gzip ships with Nginx; brotli typically requires the ngx_brotli module (available in nginx-extras on Ubuntu, or via custom builds).

gzip basics

gzip                on;
gzip_vary           on;             # send Vary: Accept-Encoding so caches behave
gzip_proxied        any;
gzip_min_length     1024;           # don't bother for tiny responses
gzip_comp_level     5;              # 1-9; 5 is a good cost/ratio balance
gzip_types
    text/plain text/css text/xml text/javascript application/javascript
    application/json application/xml application/xml+rss application/atom+xml
    image/svg+xml font/woff font/woff2;
# Note: image/jpeg, png, webp, etc. are already compressed — leave them out.

# If you have brotli compiled in:
brotli              on;
brotli_comp_level   5;
brotli_types        text/plain text/css application/javascript application/json image/svg+xml;

Security Headers

Nginx can set the cheap, high-value headers most apps forget about.

Security header block

# Inside the HTTPS server block
add_header X-Content-Type-Options    "nosniff" always;
add_header X-Frame-Options           "DENY" always;            # or SAMEORIGIN
add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
add_header Permissions-Policy        "geolocation=(), microphone=(), camera=()" always;

# Content-Security-Policy is highly app-specific. Start strict, then loosen as needed.
# Example for a vanilla server-rendered site:
add_header Content-Security-Policy   "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'" always;

The always flag matters: without it, Nginx only adds the header on 2xx/3xx responses. Error pages would skip it — which is exactly when an attacker probing for misconfigurations cares.

Use the CSP Evaluator and Mozilla Observatory to tune CSP for your specific app.

Worked Example: SPA + API on Subdomains

Putting it all together. app.example.com serves a static SPA from disk and forwards /api calls (and WebSocket) to a pool of API workers on api.example.com.

/etc/nginx/conf.d/example.com.conf

# Shared map for websocket upgrade
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# Upstream pool of API workers
upstream api_backend {
    least_conn;
    server 10.0.0.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:3000 max_fails=3 fail_timeout=30s;
    keepalive 32;
}

# ---------- HTTP → HTTPS redirect ----------
server {
    listen 80;
    server_name app.example.com api.example.com;

    location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
    location / { return 301 https://$host$request_uri; }
}

# ---------- SPA frontend ----------
server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;

    include /etc/nginx/snippets/tls_modern.conf;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header X-Frame-Options           "DENY" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;

    root /var/www/spa;
    index index.html;

    # Long-cache fingerprinted assets
    location ~* \.(?:css|js|woff2?|svg|png|jpg|jpeg|webp|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # SPA history routing — fall back to index.html for unknown paths
    location / {
        try_files $uri $uri/ /index.html;
    }
}

# ---------- API backend (with WebSocket) ----------
server {
    listen 443 ssl;
    http2 on;
    server_name api.example.com;

    include /etc/nginx/snippets/tls_modern.conf;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header Referrer-Policy           "no-referrer" always;

    location / {
        include /etc/nginx/snippets/proxy_headers.conf;
        proxy_pass http://api_backend;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
    }
}

Testing & Reloading

Always test before reloading. nginx -t validates syntax and that referenced files exist.

Safe reload workflow

# Test config
sudo nginx -t

# Reload (graceful, doesn't drop existing connections)
sudo systemctl reload nginx
# or: sudo nginx -s reload

# View the cert + protocol in use
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# Lint headers from outside
curl -sI https://app.example.com | sed -n '1,/^$/p'

Then run an external audit: SSL Labs for the TLS config and Mozilla Observatory for the headers.

Summary

  • ✓ Use server + location + proxy_pass for the basics.
  • ✓ Always set Host / X-Real-IP / X-Forwarded-For / X-Forwarded-Proto.
  • ✓ Use upstream with least_conn for load balancing; ip_hash only when stateful.
  • ✓ TLS 1.2 + 1.3 only, Mozilla intermediate ciphers, ssl_prefer_server_ciphers off.
  • ✓ HSTS with a long max-age once you're confident in HTTPS coverage.
  • ✓ OCSP stapling — and don't forget the resolver directive.
  • ✓ Let's Encrypt via certbot's nginx plugin; auto-renews via systemd timer.
  • ✓ WebSockets need Upgrade / Connection headers and HTTP/1.1.
  • ✓ Security headers — always remember the always flag.

An Nginx box configured this way passes SSL Labs A+ and Mozilla Observatory A, with sane upstream load balancing and WebSocket support. From here you can layer on rate limiting (limit_req), client cert auth (ssl_client_certificate), or HTTP/3 once it stabilizes in your build.