TLS termination is the point where an encrypted client connection is decrypted, usually at a reverse proxy or load balancer in front of your application. The proxy holds the certificate and private key, completes the handshake with the client and forwards the request to a backend as plain HTTP or over a new TLS connection.
Terminating at the edge solves concrete problems. Certificates live on a few proxies instead of every app server. The proxy can read the HTTP request, so it can route by host and path, set headers and enforce one cipher policy. The trade-off is that traffic behind the proxy is only as private as the network it crosses.
TLS termination by default on Cycle
Terminating TLS on one proxy is a config file. It gets more expensive with every proxy you add. Each one needs the certificate and key, the same protocol policy, a reload after every renewal and forwarded headers the backends know how to trust.
If that's not something you want to manage yourself, Cycle handles it in the control plane. Every environment comes with a load balancer service, and TLS terminates there. Certificates for LINKED DNS records with TLS enabled are generated and renewed automatically through Let's Encrypt and installed on the load balancer.
After termination, the load balancer forwards traffic to your containers over the private network, for example from port 443 on the load balancer to port 80 on the container. It sets X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto, so the application still sees the original client and scheme. HTTP to HTTPS redirects are available for records in hosted zones. The details are in the load balancer docs and the TLS certificate docs.
If you run your own servers, or want to understand what Cycle is doing for you, the rest of this page walks through configuring TLS termination by hand.
How TLS termination works
There are three ways a proxy can handle TLS, and they differ in where decryption happens:
Pattern | Where TLS ends | Proxy reads HTTP | Backend needs a certificate | Use it when |
|---|---|---|---|---|
Termination (offload) | At the proxy. Plain HTTP to the backend. | Yes | No | The proxy-to-backend network is private and trusted |
Re-encryption (bridging) | At the proxy, then a new TLS session to the backend | Yes | Yes, often from an internal CA | You need HTTP routing and an encrypted hop across a network you don't trust |
Passthrough | At the backend. The proxy forwards TCP bytes. | No, only the SNI name | Yes | The backend must own the keys, or terminates mTLS itself |
Passthrough still allows routing by hostname, because the client sends the server name in the unencrypted ClientHello. The proxy reads that SNI field, picks a backend and never sees anything else.
Forwarded headers
Termination changes what the backend sees. Every request arrives from the proxy's IP address over plain HTTP, so the application loses the client address and believes the request was insecure. Proxies restore that context with headers:
Header | Carries |
|---|---|
| Client IP, with each proxy appending the address it received the request from |
| The scheme the client used, |
| The |
| The standardized form of all three (RFC 7239), less widely consumed |
Clients can send these headers too. A backend must only trust them when the connection comes from a known proxy address, or anyone can spoof their IP by setting X-Forwarded-For.
Before you start
The examples use one proxy and two application servers on a private network:
Host | Address | Role |
|---|---|---|
|
| Nginx, terminates TLS |
|
| Application, plain HTTP |
|
| Application, plain HTTP |
The site is app.example.com, and a certificate for it already exists at /etc/letsencrypt/live/app.example.com/. If you don't have one yet, enable only the port 80 server block from Step 2 and reload Nginx. Then request the certificate with Certbot's certonly --webroot -w /var/www/letsencrypt and add the 443 block afterward, because Nginx won't load a config that points at missing certificate files.
Step 1: Install Nginx
sudo apt install nginxsudo mkdir -p /var/www/letsencryptnginx -vStep 2: Configure termination
Create /etc/nginx/sites-available/app.example.com:
upstream app_backend { server 10.0.0.11:8080; server 10.0.0.12:8080; keepalive 32;}
server { listen 80; listen [::]:80; server_name app.example.com;
location /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
location / { return 301 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;
add_header Strict-Transport-Security "max-age=63072000" always;
location / { proxy_pass http://app_backend; proxy_http_version 1.1; proxy_set_header Connection ""; 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; }}What the key lines do:
- The port 80 block serves ACME challenges from disk and redirects everything else to HTTPS. Without the challenge location, HTTP-01 renewals would follow the redirect and land on the backend.
http2 on;needs Nginx 1.25.1 or newer. Ubuntu 24.04 packages 1.24, which rejects it as an unknown directive. There, delete that line and writelisten 443 ssl http2;andlisten [::]:443 ssl http2;instead.ssl_certificatetakes the full chain, not the leaf alone. Serving only the leaf works in some browsers and fails in most API clients.proxy_pass http://app_backendis where termination happens. The client spoke TLS to Nginx, and Nginx speaks plain HTTP to the upstream.proxy_http_version 1.1with an emptyConnectionheader letskeepalive 32reuse backend connections instead of opening one per request.$proxy_add_x_forwarded_forappends the client address to anyX-Forwarded-Foralready present.$schemeishttpsin this block.- The HSTS header tells browsers to use HTTPS for this hostname for two years. Browsers cache it, so add it only once HTTPS works reliably here. Adding
includeSubDomainswould extend it to every subdomain.
Enable the site:
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/Step 3: Apply modern TLS settings
Put protocol and cipher policy in the http context so every server block shares it. On Ubuntu and Debian, the stock /etc/nginx/nginx.conf already sets ssl_protocols and ssl_prefer_server_ciphers under "SSL Settings". Comment out those two lines first, or nginx -t fails with a duplicate directive error.
Create /etc/nginx/conf.d/tls.conf, based on Mozilla's intermediate profile:
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;ssl_prefer_server_ciphers off;ssl_session_cache shared:TLS:10m;ssl_session_timeout 1d;ssl_session_tickets off;What each line does:
- TLS 1.0 and 1.1 are off. Every current browser and HTTP library speaks 1.2 or 1.3.
ssl_ciphersonly governs TLS 1.2. The TLS 1.3 suites are all modern AEAD ciphers and OpenSSL enables them by default.ssl_prefer_server_ciphers offlets the client choose. Every suite on the list is strong, and phones without AES hardware pick ChaCha20.- The shared session cache lets returning clients resume without a full handshake. Tickets are off, so all resumption goes through that cache. Nginx 1.23.2 and newer rotates ticket keys automatically when the cache is shared, so turning tickets on is also safe.
Leave ssl_ecdh_curve at its default. With OpenSSL 3.5 or newer, the default key exchange groups already include the hybrid post-quantum X25519MLKEM768. Skip ssl_stapling for Let's Encrypt certificates, because Let's Encrypt shut down its OCSP service in 2025.
Step 4: Make the backends trust the proxy
On each app server, accept traffic on 8080 from the proxy only. With nftables and an existing inet filter table:
sudo nft add rule inet filter input iifname != "lo" tcp dport 8080 ip saddr != 10.0.0.5 dropThe lo exception keeps local health checks working. The rule covers IPv4 only, so add a matching ip6 saddr rule if the backends listen on IPv6.
Then tell the application to trust forwarded headers from 10.0.0.5 and nobody else. In Express that's app.set('trust proxy', '10.0.0.5'). In Django, set SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') alongside the firewall rule. If the backend is itself Nginx, the realip module does it:
set_real_ip_from 10.0.0.5;real_ip_header X-Forwarded-For;real_ip_recursive on;Step 5: Test and reload
sudo nginx -tsudo systemctl reload nginxnginx -t prints syntax is ok and test is successful. A reload keeps existing connections open while new workers pick up the config.
Step 6: Verify termination
Confirm the handshake from outside:
openssl s_client -connect app.example.com:443 -servername app.example.com </dev/null 2>/dev/null \ | grep -E '^(New,|Verification)'Verification: OKNew, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384Then confirm what the backend receives. On app-1, capture traffic from the proxy while you load the site:
sudo tcpdump -l -i any -A -s0 'tcp dst port 8080' | grep -i '^x-forwarded'You should see readable HTTP headers such as X-Forwarded-Proto: https and X-Forwarded-For: 198.51.100.7, with your own client address in place of the example. Readable headers are the proof that TLS ended at edge-1, and the reason the proxy-to-backend network has to be private. For a full protocol and cipher audit, run testssl.sh against the public hostname.
Variant: re-encryption and passthrough
When the hop from proxy to backend crosses a network you don't control, re-encrypt. Point proxy_pass at an HTTPS upstream and turn on verification, which Nginx leaves off by default:
location / { proxy_pass https://app_backend_tls; proxy_ssl_server_name on; proxy_ssl_name app.internal.example.com; proxy_ssl_verify on; proxy_ssl_trusted_certificate /etc/nginx/internal-ca.pem; proxy_ssl_protocols TLSv1.2 TLSv1.3;}Without proxy_ssl_verify on, Nginx encrypts the hop but accepts any certificate, which defeats the point. proxy_ssl_name sets both the SNI and the name checked against the backend's certificate.
For passthrough, the proxy works at layer 4 and routes on SNI. In HAProxy that's a mode tcp frontend that picks a backend from req.ssl_sni. The backend then sees the proxy's address, so add PROXY protocol (send-proxy-v2 in HAProxy) if it needs the client IP.
Troubleshooting
Nginx logs handshake and upstream errors to /var/log/nginx/error.log. Raise the level to info temporarily to see per-connection TLS failures.
Symptom | Likely cause | Fix |
|---|---|---|
| The app sees plain HTTP and redirects to HTTPS, forever | Make the app trust |
Every client in app logs has the proxy's IP | Backend ignores | Configure the framework's trusted proxy setting or |
| Client is speaking TLS to a listener without | Add |
One hostname gets another site's certificate | SNI didn't match any | Fix |
| Re-encryption is on and the backend's certificate isn't signed by the trusted CA or doesn't match | Fix the CA bundle or the name |
Clients see an expired certificate even though Certbot renewed it | Certbot renewed the files but Nginx never reloaded | Add a deploy hook that runs |
Running TLS termination past one proxy
One proxy is a single point of failure, so production setups run two or more (see load balancing and high availability). Every one of them needs the same certificates, the same tls.conf and a reload after each renewal. The session cache is per host, so a client that lands on a different proxy pays for a full handshake.
The trust lists drift too. Each backend's firewall rule and trusted-proxy setting names specific proxy addresses, and adding a proxy means touching every backend. Moving to re-encryption adds an internal CA, with its own issuance and rotation, on top of the public certificates.
None of this is hard on its own. It's the inventory of certificates, proxies, policies and trust lists that grows, and that inventory is what Cycle's load balancer manages for you.
One proxy holds a certificate, but a fleet of proxies holds a policy you have to keep in sync.