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 haproxysudo dnf install -y haproxyInstall 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 certbotsudo dnf install -y certbotStop 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 haproxyObtain 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-interactiveAlternative: 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.comCertbot 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/certssudo 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.pemConfigure 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.httpConfigure 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_backendSet 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/.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:8888Run 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 backupThe 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-ticketsfrontend 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_backendFor 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.cfgsudo systemctl restart haproxy
sudo systemctl enable haproxyAutomate 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 haproxysudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.sh
sudo chown root:root /etc/letsencrypt/renewal-hooks/deploy/haproxy-reload.shThe 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-runCheck that the systemd timer for automatic renewal is active, since Certbot installs this by default on most distributions.
systemctl list-timers | grep certbotVerify 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.sockRun an SSL Labs style check locally with testssl.sh, or verify cipher restrictions directly.
openssl s_client -connect example.com:443 -tls1_1This last command should fail to connect, confirming TLS 1.1 is disabled.
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| HAProxy fails to start after adding bind ssl | PEM bundle missing or malformed | Verify 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 use | HAProxy is bound to port 80 during standalone challenge | Switch to DNS challenge or add the ACME ACL exception shown above |
| Browser shows certificate warning | PEM bundle not reloaded after renewal | Confirm the deploy hook ran: check /var/log/letsencrypt/letsencrypt.log and reload HAProxy manually |
| Backend marked down unexpectedly | Health check endpoint returns non-200 or times out | Test the check URL directly with curl -i http://203.0.113.10:8080/health and adjust inter/fall/rise |
| Weak cipher still negotiated | Old config cached or client forcing legacy TLS | Run haproxy -c -f /etc/haproxy/haproxy.cfg to confirm the active config, then reload |
| Permission denied reading PEM file | File owned by wrong user or overly restrictive mode | Set 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
- Configure HAProxy load balancing with multiple backend servers
- Set up HAProxy high availability with keepalived clustering
- Configure HAProxy with Consul for dynamic service discovery
- Configure HAProxy advanced routing with ACLs and maps
- Implement HAProxy rate limiting and DDoS protection
- Monitor HAProxy with Prometheus and Grafana dashboards
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# HAProxy + Let's Encrypt SSL termination installer
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}$*${NC}"; }
log_warn() { echo -e "${YELLOW}$*${NC}"; }
log_error() { echo -e "${RED}$*${NC}"; }
usage() {
echo "Usage: $0 -d <domain> [-e <email>] [-b <backend_host:port>] [--dns-challenge]"
echo
echo " -d Primary domain (required), e.g. example.com"
echo " -e Admin email for Let's Encrypt (default: admin@<domain>)"
echo " -b Backend server address:port (default: 127.0.0.1:8080)"
echo " --dns-challenge Use DNS challenge instead of standalone HTTP challenge"
exit 1
}
DOMAIN=""
EMAIL=""
BACKEND="127.0.0.1:8080"
USE_DNS_CHALLENGE=false
while [ $# -gt 0 ]; do
case "$1" in
-d) DOMAIN="$2"; shift 2 ;;
-e) EMAIL="$2"; shift 2 ;;
-b) BACKEND="$2"; shift 2 ;;
--dns-challenge) USE_DNS_CHALLENGE=true; shift ;;
*) usage ;;
esac
done
[ -z "$DOMAIN" ] && usage
[ -z "$EMAIL" ] && EMAIL="admin@${DOMAIN}"
# ---------------------------------------------------------------------------
# Prerequisite checks
# ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
log_error "This script must be run as root (use sudo)."
exit 1
fi
CERT_ISSUED=false
ROLLBACK_NEEDED=false
cleanup_on_error() {
log_error "Error occurred. Rolling back changes..."
if [ "$ROLLBACK_NEEDED" = true ] && [ -f "$HAPROXY_CFG.bak" ]; then
mv -f "$HAPROXY_CFG.bak" "$HAPROXY_CFG"
log_warn "Restored previous HAProxy config."
fi
systemctl restart haproxy 2>/dev/null || true
exit 1
}
trap cleanup_on_error ERR
# ---------------------------------------------------------------------------
# [1/9] Detect distro
# ---------------------------------------------------------------------------
echo "[1/9] Detecting distribution..."
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"; PKG_INSTALL="apt install -y"
HAPROXY_CFG="/etc/haproxy/haproxy.cfg"
FIREWALL_CMD="ufw"
;;
almalinux|rocky|centos|rhel|ol|fedora)
PKG_MGR="dnf"; PKG_INSTALL="dnf install -y"
HAPROXY_CFG="/etc/haproxy/haproxy.cfg"
FIREWALL_CMD="firewalld"
;;
amzn)
PKG_MGR="yum"; PKG_INSTALL="yum install -y"
HAPROXY_CFG="/etc/haproxy/haproxy.cfg"
FIREWALL_CMD="firewalld"
;;
*) log_error "Unsupported distro: $ID"; exit 1 ;;
esac
else
log_error "/etc/os-release not found. Cannot detect distro."
exit 1
fi
log_info "Detected: $ID (package manager: $PKG_MGR)"
# ---------------------------------------------------------------------------
# [2/9] Install HAProxy and Certbot
# ---------------------------------------------------------------------------
echo "[2/9] Installing HAProxy and Certbot..."
if [ "$PKG_MGR" = "apt" ]; then
apt update -y
fi
$PKG_INSTALL haproxy certbot
# ---------------------------------------------------------------------------
# [3/9] Configure firewall for HTTP/HTTPS
# ---------------------------------------------------------------------------
echo "[3/9] Configuring firewall..."
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
ufw allow 80/tcp
ufw allow 443/tcp
log_info "UFW rules added for ports 80/443."
elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
log_info "firewalld rules added for HTTP/HTTPS."
else
log_warn "No active supported firewall detected; skipping firewall configuration."
fi
# ---------------------------------------------------------------------------
# [4/9] Stop HAProxy if using standalone challenge
# ---------------------------------------------------------------------------
echo "[4/9] Preparing for certificate issuance..."
HAPROXY_WAS_RUNNING=false
if systemctl is-active --quiet haproxy; then
HAPROXY_WAS_RUNNING=true
fi
if [ "$USE_DNS_CHALLENGE" = false ]; then
if [ "$HAPROXY_WAS_RUNNING" = true ]; then
log_warn "Stopping HAProxy temporarily to free port 80..."
systemctl stop haproxy
fi
fi
# ---------------------------------------------------------------------------
# [5/9] Obtain certificate
# ---------------------------------------------------------------------------
echo "[5/9] Requesting certificate from Let's Encrypt..."
if [ -d "/etc/letsencrypt/live/${DOMAIN}" ]; then
log_warn "Certificate for ${DOMAIN} already exists. Skipping issuance."
else
if [ "$USE_DNS_CHALLENGE" = true ]; then
log_warn "DNS challenge selected. You will need to add a TXT record manually."
certbot certonly --manual --preferred-challenges dns \
-d "${DOMAIN}" -d "*.${DOMAIN}" \
--agree-tos -m "${EMAIL}"
else
certbot certonly --standalone -d "${DOMAIN}" \
--agree-tos -m "${EMAIL}" --non-interactive
fi
fi
CERT_ISSUED=true
# Restart HAProxy now that port 80 is free again (it will be reconfigured later)
if [ "$USE_DNS_CHALLENGE" = false ] && [ "$HAPROXY_WAS_RUNNING" = true ]; then
systemctl start haproxy || true
fi
# ---------------------------------------------------------------------------
# [6/9] Build combined PEM bundle
# ---------------------------------------------------------------------------
echo "[6/9] Building combined PEM bundle for HAProxy..."
mkdir -p /etc/haproxy/certs
chmod 700 /etc/haproxy/certs
chown root:root /etc/haproxy/certs
LE_LIVE="/etc/letsencrypt/live/${DOMAIN}"
PEM_BUNDLE="/etc/haproxy/certs/${DOMAIN}.pem"
if [ ! -f "${LE_LIVE}/fullchain.pem" ] || [ ! -f "${LE_LIVE}/privkey.pem" ]; then
log_error "Certificate files not found at ${LE_LIVE}."
exit 1
fi
cat "${LE_LIVE}/fullchain.pem" "${LE_LIVE}/privkey.pem" > "${PEM_BUNDLE}"
chmod 600 "${PEM_BUNDLE}"
chown root:root "${PEM_BUNDLE}"
log_info "PEM bundle created at ${PEM_BUNDLE}."
# ---------------------------------------------------------------------------
# [7/9] Write HAProxy configuration
# ---------------------------------------------------------------------------
echo "[7/9] Writing HAProxy configuration..."
[ -f "$HAPROXY_CFG" ] && cp "$HAPROXY_CFG" "${HAPROXY_CFG}.bak"
ROLLBACK_NEEDED=true
cat > "$HAPROXY_CFG" <<EOF
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
ssl-default-bind-ciphers ECDHE+AESGCM:ECDHE+CHACHA20:!aNULL:!MD5:!DSS
ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
# Redirect all plain HTTP traffic to HTTPS
frontend http_front
bind *:80
http-request redirect scheme https unless { ssl_fc }
# TLS termination point
frontend https_front
bind *:443 ssl crt ${PEM_BUNDLE}
http-request set-header X-Forwarded-Proto https
default_backend app_back
backend app_back
balance roundrobin
option httpchk GET /
http-check expect status 200
server web1 ${BACKEND} check ssl verify none
EOF
chmod 644 "$HAPROXY_CFG"
chown root:root "$HAPROXY_CFG"
# Validate configuration before restarting service
if ! haproxy -c -f "$HAPROXY_CFG"; then
log_error "HAProxy configuration test failed."
exit 1
fi
# ---------------------------------------------------------------------------
# [8/9] Enable service and set up auto-renewal
# ---------------------------------------------------------------------------
echo "[8/9] Enabling HAProxy service and configuring renewal hook..."
systemctl enable haproxy
systemctl restart haproxy
ROLLBACK_NEEDED=false
RENEW_HOOK_DIR="/etc/letsencrypt/renewal-hooks/deploy"
mkdir -p "$RENEW_HOOK_DIR"
cat > "${RENEW_HOOK_DIR}/haproxy-rebuild.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
cat /etc/letsencrypt/live/${DOMAIN}/fullchain.pem /etc/letsencrypt/live/${DOMAIN}/privkey.pem > ${PEM_BUNDLE}
chmod 600 ${PEM_BUN
Review the script before running. Execute with: bash install.sh