Cycle's hosted MCP is live. Your AI tooling just learned DevOps.Read the announcement

Configuring TLS Termination.

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

X-Forwarded-For

Client IP, with each proxy appending the address it received the request from

X-Forwarded-Proto

The scheme the client used, https after termination

X-Forwarded-Host

The Host header the client sent

Forwarded

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

edge-1

203.0.113.10 public, 10.0.0.5 private

Nginx, terminates TLS

app-1

10.0.0.11:8080

Application, plain HTTP

app-2

10.0.0.12:8080

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 nginx
sudo mkdir -p /var/www/letsencrypt
nginx -v

Step 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 write listen 443 ssl http2; and listen [::]:443 ssl http2; instead.
  • ssl_certificate takes 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_backend is where termination happens. The client spoke TLS to Nginx, and Nginx speaks plain HTTP to the upstream.
  • proxy_http_version 1.1 with an empty Connection header lets keepalive 32 reuse backend connections instead of opening one per request.
  • $proxy_add_x_forwarded_for appends the client address to any X-Forwarded-For already present. $scheme is https in 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 includeSubDomains would 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_ciphers only governs TLS 1.2. The TLS 1.3 suites are all modern AEAD ciphers and OpenSSL enables them by default.
  • ssl_prefer_server_ciphers off lets 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 drop

The 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 -t
sudo systemctl reload nginx

nginx -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: OK
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384

Then 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

ERR_TOO_MANY_REDIRECTS

The app sees plain HTTP and redirects to HTTPS, forever

Make the app trust X-Forwarded-Proto from the proxy

Every client in app logs has the proxy's IP

Backend ignores X-Forwarded-For

Configure the framework's trusted proxy setting or set_real_ip_from

wrong version number from openssl s_client

Client is speaking TLS to a listener without ssl

Add ssl to the listen 443 line

One hostname gets another site's certificate

SNI didn't match any server_name, so the default server answered

Fix server_name and always test with -servername

upstream SSL certificate verify error

Re-encryption is on and the backend's certificate isn't signed by the trusted CA or doesn't match proxy_ssl_name

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 systemctl reload nginx

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.

Cookies

Cookies Preferences

We run basic, anonymous analytics by default to measure site traffic. By clicking "Accept," you allow additional cookies for advanced app improvements and tailored advertising. Choose what you share by clicking "Customize."