TLS termination is the point where an encrypted HTTPS connection is decrypted. In simple setups that is the web server itself; in larger ones it is a reverse proxy, load balancer or CDN in front of the application. Choosing where TLS terminates affects certificate management, performance, security and what the backend sees.
Before you start
- Understand reverse proxies: forward proxy vs reverse proxy vs load balancer.
- A server with Nginx or HAProxy and a valid certificate.
Three ways to handle TLS at a proxy
| Mode | Proxy decrypts? | Proxy → backend | Typical use |
|---|---|---|---|
| Termination (offload) | Yes | Plain HTTP | Backend on same host or private network |
| Re-encryption (bridging) | Yes | New HTTPS connection | Backend across untrusted networks, compliance |
| Passthrough | No (routes by SNI) | Original encrypted stream | Backend must own the certificate, end-to-end TLS |
Why terminate TLS at the edge
- One place for certificates. Renew with Certbot or acme.sh on the proxy only.
- Layer 7 features. Only a proxy that sees the HTTP request can route by path, cache, compress, add headers or apply a WAF.
- Modern protocols. The proxy can speak HTTP/2 and HTTP/3 to browsers while the backend speaks simple HTTP/1.1.
- Offload CPU. Handshakes are handled by a process tuned for it.
The trade-off: traffic between proxy and backend is plain text. That is fine over loopback or a private VLAN, not across the internet.
Terminate TLS on Nginx in front of an app
server {
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;
location / {
proxy_pass http://127.0.0.1:8080;
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;
}
}Bind the backend to 127.0.0.1 so nobody can bypass the proxy.
Tell the backend the request was HTTPS
After termination the app sees plain HTTP. Without X-Forwarded-Proto: https many apps build http:// links or loop redirecting to HTTPS. Configure the app to trust the header only from the proxy:
- WordPress: set
$_SERVER['HTTPS'] = 'on'inwp-config.phpwhen the header ishttps. - Laravel: configure trusted proxies.
- Apache backend:
mod_remoteipfor the IP andSetEnvIf X-Forwarded-Proto https HTTPS=on.
Never trust forwarded headers from arbitrary clients; anyone can send them.
Re-encrypt to the backend
proxy_pass https://10.0.0.20:8443;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/nginx/backend-ca.pem;
proxy_ssl_server_name on;Without proxy_ssl_verify on the connection is encrypted but not authenticated.
TLS passthrough with HAProxy
frontend tls_in
bind :443
mode tcp
tcp-request inspect-delay 5s
tcp-request content accept if { req_ssl_hello_type 1 }
use_backend app1 if { req_ssl_sni -i app1.example.com }
backend app1
mode tcp
server s1 10.0.0.21:443The proxy never sees HTTP, so no header injection, caching or path routing is possible.
Troubleshooting
- ERR_TOO_MANY_REDIRECTS: the backend does not know the request was HTTPS. Pass and trust X-Forwarded-Proto. See fixing redirect loops.
- All logs show 127.0.0.1: restore the client IP from X-Forwarded-For with the real IP module.
- Mixed content warnings: the app generates
http://asset URLs for the same reason.
FAQ: TLS termination
Is TLS termination secure?
Yes when the proxy-to-backend path is local or private. Use re-encryption otherwise.
Does Cloudflare terminate TLS?
Yes. In "Full (strict)" mode it re-encrypts to your origin with a valid certificate.
Where should HSTS be set?
On the component that talks to browsers, the terminating proxy.
