When a reverse proxy or load balancer sits in front of your applications, you have to decide where HTTPS is decrypted. There are three strategies: terminate TLS at the proxy and talk plain HTTP to the backends (offloading), forward the encrypted stream untouched (passthrough), or terminate at the proxy and open a new TLS connection to the backend (re-encryption). This guide compares the three, shows a working configuration for each with Nginx and HAProxy on Ubuntu 24.04, and explains how to choose.

Prerequisites

To try the examples you need:

  • A proxy server and at least one backend server running Ubuntu 24.04 LTS, for example CubePath VPS instances connected through a private network.
  • A non-root user with sudo privileges on each server.
  • A domain with an A record pointing to the proxy, and a certificate for it (for example from Let's Encrypt with Certbot).
  • Nginx (sudo apt install nginx, version 1.24 on Ubuntu 24.04) or HAProxy (sudo apt install haproxy, version 2.8), depending on the example.

In the examples, the proxy is reachable as your_domain and the backend has the private IP 10.0.0.11. Replace both with your own values.

The three strategies at a glance

OffloadingPassthroughRe-encryption
Where TLS is decryptedProxy onlyBackend onlyProxy and backend
Proxy to backend trafficPlain HTTPEncrypted (client session)Encrypted (new session)
Where the public certificate livesProxyEvery backendProxy (backends use an internal certificate)
Proxy sees HTTP (paths, headers, cookies)YesNo, only the SNI hostnameYes
Path routing, header rewrites, caching, WAFYesNoYes
Client IP at the backendX-Forwarded-For headerPROXY protocolX-Forwarded-For header
TLS CPU costProxyBackendsProxy and backends
Client certificate (mTLS) checked byProxyBackendProxy

Strategy 1 - TLS offloading

With offloading, the proxy holds the certificate, decrypts every request and forwards plain HTTP to the backend. This is the most common setup: certificates are managed in one place, backends do no TLS work, and the proxy can route by path, add headers, cache and compress.

The trade-off is that traffic between the proxy and the backends travels unencrypted. That is acceptable only when that path is trusted, such as a private network or the same host.

Configure it in Nginx on the proxy:

sudo nano /etc/nginx/sites-available/app.conf
upstream app_plain {
    server 10.0.0.11:8080;
    keepalive 16;
}

server {
    listen 80;
    listen [::]:80;
    server_name your_domain;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name your_domain;

    ssl_certificate /etc/letsencrypt/live/your_domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;

    location / {
        proxy_pass http://app_plain;
        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 https;
    }
}

X-Forwarded-Proto https is important: without it, many frameworks think the request arrived over HTTP and generate http:// links or redirect loops. Configure the application to trust these headers only from the proxy's IP address.

Enable the site and reload:

sudo ln -s /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Make sure the backend port 8080 only accepts connections from the proxy. On the backend:

sudo ufw allow from 10.0.0.5 to any port 8080 proto tcp

Here 10.0.0.5 is the proxy's private IP.

Strategy 2 - TLS passthrough

With passthrough, the proxy does not decrypt anything. It reads the SNI hostname from the unencrypted TLS ClientHello, picks a backend and forwards the raw TCP stream. The backend holds the certificate and private key and terminates TLS itself.

Use passthrough when the proxy must not see the traffic (for example a shared load balancer in front of tenants that manage their own certificates), when a backend must verify client certificates itself, or for non-HTTP TLS services. You lose everything that needs HTTP visibility: no path routing, no header injection, no caching, and no HTTP health checks on the real content.

HAProxy handles SNI routing in TCP mode:

sudo nano /etc/haproxy/haproxy.cfg

Append:

frontend tls_passthrough
    bind :443
    mode tcp
    option tcplog
    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }
    use_backend app1_tls if { req.ssl_sni -i app1.your_domain }
    default_backend app2_tls

backend app1_tls
    mode tcp
    server app1 10.0.0.11:443 check send-proxy-v2

backend app2_tls
    mode tcp
    server app2 10.0.0.12:443 check send-proxy-v2

tcp-request inspect-delay and req.ssl_hello_type 1 make HAProxy wait until the ClientHello has arrived so that req.ssl_sni is available.

Because the connection comes from the proxy, the backend would see the proxy's IP as the client. send-proxy-v2 prepends a PROXY protocol header with the real client address. The backend must be configured to expect it, or every connection fails. On a backend running Nginx:

server {
    listen 443 ssl proxy_protocol;
    server_name app1.your_domain;

    ssl_certificate /etc/letsencrypt/live/app1.your_domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app1.your_domain/privkey.pem;

    set_real_ip_from 10.0.0.5;
    real_ip_header proxy_protocol;

    root /var/www/html;
}

Validate and reload HAProxy on the proxy:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy

Strategy 3 - Re-encryption

Re-encryption (also called TLS bridging or end-to-end TLS) terminates the client connection at the proxy, then opens a separate TLS connection to the backend. The proxy keeps full HTTP visibility and traffic stays encrypted on the internal network. This is what compliance frameworks such as PCI DSS usually expect when traffic crosses networks you do not fully control.

The backend presents a certificate for an internal name, for example app.internal, issued by a private CA or any CA the proxy trusts. The public certificate stays on the proxy.

Configure Nginx on the proxy to connect over HTTPS and verify the backend certificate:

upstream app_tls {
    server 10.0.0.11:8443;
    keepalive 16;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name your_domain;

    ssl_certificate /etc/letsencrypt/live/your_domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;

    location / {
        proxy_pass https://app_tls;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;

        proxy_ssl_verify on;
        proxy_ssl_trusted_certificate /etc/nginx/internal-ca.crt;
        proxy_ssl_name app.internal;
        proxy_ssl_server_name on;
        proxy_ssl_protocols TLSv1.2 TLSv1.3;
    }
}
  • proxy_ssl_verify on is the key line. Nginx does not verify upstream certificates by default, so without it an attacker on the internal network could impersonate the backend.
  • proxy_ssl_trusted_certificate points to the CA that issued the backend certificate.
  • proxy_ssl_name is the name checked against the backend certificate, and proxy_ssl_server_name on sends it as SNI.
  • keepalive with proxy_http_version 1.1 reuses TLS connections to the backend, which removes most of the extra handshake cost.

The HAProxy equivalent is the ssl verify required ca-file parameters on the server line:

backend app_tls
    mode http
    server app1 10.0.0.11:8443 check ssl verify required ca-file /etc/haproxy/internal-ca.crt sni str(app.internal) verifyhost app.internal

Verifying which strategy is in effect

Check which certificate a client actually receives:

openssl s_client -connect your_domain:443 -servername your_domain </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer
subject=CN = your_domain
issuer=C = US, O = Let's Encrypt, CN = R11

With offloading and re-encryption, this is the proxy's certificate. With passthrough, it is the certificate installed on the backend.

For re-encryption, test the backend directly from the proxy with the internal CA to confirm the proxy can verify it:

curl --cacert /etc/nginx/internal-ca.crt --resolve app.internal:8443:10.0.0.11 https://app.internal:8443/ -o /dev/null -w "%{http_code}\n"
200

If this fails with a certificate error, Nginx will fail too and return 502 Bad Gateway, logging upstream SSL certificate verify error in /var/log/nginx/error.log.

Performance considerations

On modern CPUs with AES-NI, symmetric encryption is cheap. The expensive part is the handshake, and both HTTP keepalive and TLS session resumption avoid most handshakes. In practice:

  • Offloading puts all TLS work on the proxy, which is the easiest place to scale and tune.
  • Passthrough spreads the TLS work across the backends and makes the proxy very light, since it only copies bytes.
  • Re-encryption doubles the handshakes, but with upstream keepalive the proxy-to-backend handshakes happen rarely and the overhead is small.

Measure on your own hardware before choosing a strategy for performance reasons alone. Security and operational needs usually matter more.

Which strategy should you choose

  • Choose offloading when the proxy and backends share a trusted private network or host, and you want simple certificate management and full HTTP features. This covers most single-tenant web applications.
  • Choose re-encryption when traffic between the proxy and backends crosses a network you do not fully trust (another data center, a shared network, a cloud provider's backbone) or a compliance standard requires encryption in transit everywhere.
  • Choose passthrough when the proxy must not see plaintext, when each backend manages its own certificate, when the backend authenticates clients with mTLS itself, or when you balance non-HTTP TLS protocols.

Mixing strategies on one proxy is common: offload the public website, re-encrypt traffic to an API in another region, and pass through a tenant's traffic untouched.

Conclusion

Offloading, passthrough and re-encryption differ in one decision: where TLS is decrypted. That choice determines where certificates live, what the proxy can do with the traffic, how the backend learns the client IP and whether internal traffic is encrypted. Start with offloading on a private network, and move to re-encryption or passthrough when your security or tenancy model requires it.

As next steps, automate certificate renewal with Certbot on whichever servers hold the public certificates, set up a small private CA for internal backend certificates, and consider mutual TLS between the proxy and the backends for service-to-service authentication.