Set up HAProxy SSL termination with Let's Encrypt certificates

Intermediate 35 min Aug 05, 2026 179 views
Ubuntu 24.04 Debian 12 AlmaLinux 9 Rocky Linux 9

Learn how to terminate SSL/TLS at HAProxy using Let's Encrypt certificates, redirect HTTP to HTTPS, automate renewal with deploy hooks, and harden your cipher suites for production load balancing.

Prerequisites

  • A server with a public IP and root or sudo access
  • A registered domain name pointing to the server
  • Port 80 and 443 open in the firewall
  • Basic familiarity with HAProxy configuration syntax

What this solves

HAProxy is a fast, reliable load balancer, but it does not fetch or manage TLS certificates on its own. This tutorial shows you how to obtain free certificates from Let's Encrypt with Certbot, combine them into the PEM bundle HAProxy expects, and wire up automatic renewal so certificates never expire silently.

You will also configure HTTP to HTTPS redirection, backend health checks, load balancing, and modern cipher suites so your termination point is both functional and hardened.

Step-by-step configuration

Install HAProxy

HAProxy handles the TLS termination and forwards decrypted traffic to your backend servers.

sudo apt update
sudo apt install -y haproxy
sudo dnf install -y haproxy

Install Certbot

Certbot requests and renews certificates from Let's Encrypt. Install the standalone plugin so it can bind to port 80 temporarily during issuance.

sudo apt install -y certbot
sudo dnf install -y certbot

Stop HAProxy before issuing certificates

The standalone challenge needs port 80 free. Stop HAProxy temporarily, or skip this step if you plan to use the DNS challenge instead.

sudo systemctl stop haproxy

Obtain a certificate with the standalone challenge

This works when port 80 is reachable from the internet and no other process is bound to it. Replace example.com with your actual domain.

sudo certbot certonly --standalone -d example.com -d www.example.com --agree-tos -m admin@example.com --non-interactive
Note: If HAProxy must stay online, use the DNS challenge instead so port 80 is never touched.

Alternative: obtain a certificate with the DNS challenge

The DNS challenge lets you issue certificates without opening port 80 or stopping HAProxy, and is required for wildcard certificates. This example uses the manual DNS plugin; swap in your DNS provider's Certbot plugin for full automation.

sudo certbot certonly --manual --preferred-challenges dns -d example.com -d '*.example.com' --agree-tos -m admin@example.com

Certbot will print a TXT record to add to your DNS zone. Add it, wait for propagation, then continue the prompt to complete validation.

Build the combined PEM bundle

HAProxy expects a single file containing the certificate, intermediate chain, and private key, in that order. Create a directory to store these bundles and a script to rebuild them after every renewal.

sudo mkdir -p /etc/haproxy/certs
sudo chmod 700 /etc/haproxy/certs
sudo bash -c 'cat /etc/letsencrypt/live/example.com/fullchain.pem /etc/letsencrypt/live/example.com/privkey.pem > /etc/haproxy/certs/example.com.pem'
sudo chmod 600 /etc/haproxy/certs/example.com.pem
sudo chown root:root /etc/haproxy/certs/example.com.pem
Never use chmod 777. The PEM bundle contains your private key. Keep it owned by root with 600 permissions so only the HAProxy process, running as root or via setcap, can read it. Anything more permissive exposes your key to every local user.

Configure HAProxy global and defaults sections

These sections set process-wide behavior and sane connection defaults before you define frontends and backends.

global
    log /dev/log local0
    log /dev/log local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s
    user haproxy
    group haproxy
    daemon
    maxconn 4096

defaults
    log global
    mode http
    option httplog
    option dontlognull
    timeout connect 5s
    timeout client 30s
    timeout server 30s
    errorfile 400 /etc/haproxy/errors/400.http
    errorfile 403 /etc/haproxy/errors/403.http
    errorfile 500 /etc/haproxy/errors/500.http
    errorfile 502 /etc/haproxy/errors/502.http
    errorfile 503 /etc/haproxy/errors/503.http
    errorfile 504 /etc/haproxy/errors/504.http

Configure the HTTPS frontend with SSL termination

This frontend binds to port 443, loads the PEM bundle, and forwards traffic to a backend pool. It also sets headers so backend applications know the original request used HTTPS.

frontend https_front
    bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
    mode http
    option forwardfor
    http-request set-header X-Forwarded-Proto https
    default_backend web_backend

Set up HTTP to HTTPS redirection

All plain HTTP traffic should be redirected to HTTPS rather than served unencrypted. Add a dedicated frontend on port 80 that issues a permanent redirect.

frontend http_front
    bind *:80
    mode http
    http-request redirect scheme https code 301 unless { ssl_fc }
    default_backend web_backend
Note: Certbot's standalone challenge also needs port 80. If you keep this redirect frontend running permanently, use the DNS challenge or the HTTP-01 webroot method with an ACL exception for /.well-known/acme-challenge/ instead of stopping HAProxy for renewals.

Allow ACME HTTP-01 renewals without downtime

Add an ACL that lets Certbot's renewal requests through the redirect frontend, so you never need to stop HAProxy again after the initial certificate issuance.

frontend http_front
    bind *:80
    mode http
    acl is_acme_challenge path_beg /.well-known/acme-challenge/
    use_backend acme_backend if is_acme_challenge
    http-request redirect scheme https code 301 unless { ssl_fc } !is_acme_challenge
    default_backend web_backend

backend acme_backend
    server certbot 127.0.0.1:8888

Run Certbot's standalone challenge on an internal port so this backend can reach it during renewal, or switch entirely to the DNS challenge for a simpler setup.

Configure the backend with health checks and load balancing

The backend pool defines your application servers, the load balancing algorithm, and active health checks that remove unhealthy servers automatically.

backend web_backend
    mode http
    balance roundrobin
    option httpchk GET /health
    http-check expect status 200
    default-server inter 5s fall 3 rise 2
    server web1 203.0.113.10:8080 check
    server web2 203.0.113.11:8080 check
    server web3 203.0.113.12:8080 check backup

The fall 3 and rise 2 settings mean a server is marked down after 3 consecutive failed checks and back up after 2 consecutive successes, avoiding flapping on transient errors.

Harden SSL/TLS settings and cipher suites

Restrict HAProxy to TLS 1.2 and 1.3, disable weak ciphers, and enable HSTS so browsers refuse to downgrade to plain HTTP.

global
    ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
    ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
    ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
frontend https_front
    bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
    mode http
    http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains"
    http-request set-header X-Forwarded-Proto https
    default_backend web_backend

For a deeper dive into ACL-based traffic rules and security headers, see configure HAProxy SSL termination and security headers.

Validate and reload HAProxy

Always validate the configuration syntax before reloading, since a bad config can drop your listeners.

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl restart haproxy
sudo systemctl enable haproxy

Automate renewal with a deploy hook

Certbot can run a script automatically whenever it renews a certificate. Use this to rebuild the PEM bundle and reload HAProxy without manual steps.

sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
#!/bin/bash
set -e
DOMAIN="example.com"
cat /etc/letsencrypt/live/${DOMAIN}/fullchain.pem /etc/letsencrypt/live/${DOMAIN}/privkey.pem > /etc/haproxy/certs/${DOMAIN}.pem
chmod 600 /etc/haproxy/certs/${DOMAIN}.pem
chown root:root /etc/haproxy/certs/${DOMAIN}.pem
systemctl reload haproxy
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh
sudo chown root:root /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh

The script needs to be executable by root only, since Certbot's renewal timer runs as root. There is no reason to make it writable by other users.

Test the renewal process

Certbot ships a dry run mode that simulates renewal without touching your actual certificates, letting you confirm the hook fires correctly.

sudo certbot renew --dry-run

Check that the systemd timer for automatic renewal is active, since Certbot installs this by default on most distributions.

systemctl list-timers | grep certbot

Verify your setup

curl -I http://example.com
curl -Iv https://example.com
openssl s_client -connect example.com:443 -servername example.com < /dev/null | grep -A2 "Protocol\|Cipher"

Confirm HAProxy sees your backend servers as healthy using the runtime API or stats page.

echo "show servers state" | sudo socat stdio /run/haproxy/admin.sock

Run an SSL Labs style check locally with testssl.sh, or verify cipher restrictions directly.

openssl s_client -connect example.com:443 -tls1_1

This last command should fail to connect, confirming TLS 1.1 is disabled.

Common issues

SymptomCauseFix
HAProxy fails to start after adding bind sslPEM bundle missing or malformedVerify order: certificate, then chain, then key in one file, check with openssl x509 -in /etc/haproxy/certs/example.com.pem -noout -text
Certbot renewal fails with port 80 in useHAProxy is bound to port 80 during standalone challengeSwitch to DNS challenge or add the ACME ACL exception shown above
Browser shows certificate warningPEM bundle not reloaded after renewalConfirm the deploy hook ran: check /var/log/letsencrypt/letsencrypt.log and reload HAProxy manually
Backend marked down unexpectedlyHealth check endpoint returns non-200 or times outTest the check URL directly with curl -i http://203.0.113.10:8080/health and adjust inter/fall/rise
Weak cipher still negotiatedOld config cached or client forcing legacy TLSRun haproxy -c -f /etc/haproxy/haproxy.cfg to confirm the active config, then reload
Permission denied reading PEM fileFile owned by wrong user or overly restrictive modeSet ownership to root and mode 600, and confirm HAProxy's systemd unit runs with sufficient privilege to read it, do not use 777

Next steps

Running this in production?

Want this handled for you? Setting this up once is straightforward. Keeping certificates renewed, cipher suites current, and backend health monitored across environments is the harder part. See how we run infrastructure like this for European teams.

Automated install script

Run this to automate the entire setup

Vous ne voulez pas gérer cela vous-même ?

Nous gérons l'infrastructure des entreprises qui dépendent de leur disponibilité. Entièrement infogéré, avec un interlocuteur fixe qui connaît votre environnement.

Vous avez un interlocuteur fixe qui connaît votre installation

À son bureau à Rotterdam 17:40 · joignable par message, sans formulaire de ticket