Table of Contents – HAProxy Load Balancer with Let’s Encrypt
- Overview
- Prerequisites
- Quick Architecture
- Global Variables
- Install / Setup
- Base Configuration
- Backends: Nginx or Apache
- Reload/Enable & Health Checks
- Security / Hardening
- Performance & Optimization
- Backup & Restore
- Troubleshooting (Top issues)
- Key Takeaways & Next Steps
Overview
What are we building? One public-facing server called Load Balancer (LB) with HAProxy. It receives website traffic on ports 80 (HTTP) and 443 (HTTPS) and forwards it to two Backend web servers.
Why? To get free HTTPS certificates (from Let’s Encrypt), improve reliability (two backends), and make upgrades safer (you can update a backend while the other keeps serving).
End result: A working website at https://$DOMAIN/ using a valid TLS certificate. You will learn to install packages, place full configuration files, enable services, and verify everything.
Docs: HAProxy · acme.sh · Let’s Encrypt
Prerequisites
- Three Linux machines (VMs or cloud): 1 × LB (public DNS + public IP), 2 × Backend (reachable from LB).
- A domain name you control (e.g.,
example.com) pointing to the LB public IP. - On the LB: open
80/tcpand443/tcp. - Basic terminal access as
rootor viasudo.
Quick Architecture

Global Variables
What are variables? They are simple placeholders we define once and reuse. This reduces typing mistakes.
Where to put them? Save the following lines to a file called /root/guide-vars.sh on every node (LB and both Backends). Then “load” them with one command.
How to do that, step-by-step:
- Open a terminal on the node (LB or Backend). If you use a cloud VM, connect with SSH.
- Copy the whole code block below and paste it into the terminal. It creates the file for you automatically.
- Run the
sourcecommand (shown at the end) to load variables into the current shell. - Verify by printing one variable (e.g.,
echo $DOMAIN). If you see your domain, it worked.
cat <<'EOF' | sudo tee /root/guide-vars.sh
# <--- EDIT THESE VALUES ONLY --->
export DOMAIN="example.com" # Your website DNS name (must point to the LB public IP)
export EMAIL="[email protected]" # Your email for Let's Encrypt notices # Backend IPs (addresses of your two web servers)
export BACKEND1_IP="10.10.1.10"
export BACKEND2_IP="10.10.1.11"
export BACKEND_PORT="80" # Usually 80 for HTTP backends # ACME webroot on the LB (where HTTP challenge files are placed)
export ACME_WEBROOT="/var/www/acme"
# <--- DO NOT EDIT BELOW THIS LINE --->
EOF # Load the variables into your shell
source /root/guide-vars.sh # Health check: you should see your domain printed here
echo "Loaded DOMAIN = $DOMAIN"
Install / Setup
We install HAProxy + acme.sh on the LB and basic tools on the Backends. Follow only the block that matches your OS. If a command asks “yes/no”, type y then Enter.
Step 1 — Install packages (LB)
# Ubuntu/Debian (LB)
sudo apt update
sudo apt -y install haproxy curl socat
curl https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh | sudo sh -s email="$EMAIL"
# RHEL/Rocky/CentOS Stream/Fedora (LB)
sudo dnf -y install haproxy curl socat
curl https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh | sudo sh -s email="$EMAIL"
# Arch/Manjaro (LB)
sudo pacman -Syu --noconfirm haproxy curl socat
curl https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh | sudo sh -s email="$EMAIL"
# openSUSE/SLE (LB)
sudo zypper refresh
sudo zypper install -y haproxy curl socat
curl https://raw.githubusercontent.com/acmesh-official/acme.sh/master/acme.sh | sudo sh -s email="$EMAIL"
Step 2 — Prepare ACME webroot (LB)
This is the folder where Let’s Encrypt will drop small files to prove you own the domain. We make sure HAProxy and the temporary mini web server can read it.
sudo mkdir -p "$ACME_WEBROOT"
sudo chown -R www-data:www-data "$ACME_WEBROOT" 2>/dev/null || sudo chown -R haproxy:haproxy "$ACME_WEBROOT" 2>/dev/null || true
sudo setfacl -m u:haproxy:rwx "$ACME_WEBROOT" 2>/dev/null || true
# Health check
test -d "$ACME_WEBROOT" && echo "ACME webroot ready: $ACME_WEBROOT"
Base Configuration
We first run a temporary HTTP-only HAProxy for domain validation, then request a certificate, then replace HAProxy config with the full HTTPS configuration.
Step 3 — Temporary HAProxy for ACME (LB)
Why? Let’s Encrypt uses HTTP (port 80) to check your domain. This minimal config sends only the ACME path to a tiny Python web server we run locally.
cat <<'EOF' | sudo tee /etc/haproxy/haproxy.cfg
global log /dev/log local0 log /dev/log local1 notice user haproxy group haproxy daemon defaults log global mode http option httplog option dontlognull timeout connect 5s timeout client 30s timeout server 30s frontend http-acme bind *:80 acl acme path_beg /.well-known/acme-challenge/ use_backend acme-backend if acme default_backend acme-backend backend acme-backend server local 127.0.0.1:8080 check
EOF # Serve ACME files via a tiny local server
( cd "$ACME_WEBROOT" && nohup python3 -m http.server 8080 --bind 127.0.0.1 >/tmp/acme-web.log 2>&1 & ) || true sudo systemctl enable --now haproxy
sudo ss -tulpen | grep ':80' || true
Step 4 — Request & install certificate (LB)
Now we ask Let’s Encrypt for a certificate and store it where HAProxy can read it. The acme.sh tool also sets up auto-renew.
/root/.acme.sh/acme.sh --register-account -m "$EMAIL" || true
/root/.acme.sh/acme.sh --issue -d "$DOMAIN" --webroot "$ACME_WEBROOT" sudo mkdir -p /etc/haproxy/certs
/root/.acme.sh/acme.sh --install-cert -d "$DOMAIN" --fullchain-file /etc/haproxy/certs/$DOMAIN.fullchain.pem --key-file /etc/haproxy/certs/$DOMAIN.key.pem --reloadcmd "systemctl reload haproxy" # Some distros prefer a single PEM file (key + full chain)
sudo sh -c 'cat /etc/haproxy/certs/$DOMAIN.key.pem /etc/haproxy/certs/$DOMAIN.fullchain.pem > /etc/haproxy/certs/$DOMAIN.pem'
sudo chmod 600 /etc/haproxy/certs/$DOMAIN.*
ls -l /etc/haproxy/certs/ | awk '{print $9, $1, $3":"$4}'
Step 5 — Final HAProxy config (HTTPS + backends)
Replace the temporary config with the final one. It redirects HTTP→HTTPS, enables HTTP/2, and checks backend health.
cat <<'EOF' | sudo tee /etc/haproxy/haproxy.cfg
global log /dev/log local0 log /dev/log local1 notice user haproxy group haproxy daemon tune.ssl.default-dh-param 2048 defaults log global mode http option httplog option forwardfor option http-server-close timeout connect 5s timeout client 30s timeout server 30s default-server inter 3s fall 3 rise 2 frontend https-in bind *:443 ssl crt /etc/haproxy/certs/$DOMAIN.pem alpn h2,http/1.1 http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" http-request add-header X-Forwarded-Proto https if { ssl_fc } default_backend web-backend frontend http-in bind *:80 redirect scheme https code 301 if !{ ssl_fc } backend web-backend balance roundrobin option httpchk GET / http-check expect status 200 server web1 {BACKEND1_IP}:{BACKEND_PORT} check server web2 {BACKEND2_IP}:{BACKEND_PORT} check
EOF
sudo systemctl reload haproxy
sudo ss -tulpen | egrep ':80|:443'
Backends: Nginx or Apache
Install a real web server on each backend. Do the same on Backend1 and Backend2. After installing, create a simple test page so HAProxy health checks return HTTP 200.
Option A — Nginx (recommended for simple static sites)
# Ubuntu/Debian (Backend1 & Backend2)
sudo apt update
sudo apt -y install nginx
echo "OK $(hostname) — Nginx" | sudo tee /var/www/html/index.html
sudo systemctl enable --now nginx
curl -I http://127.0.0.1/
# RHEL/Rocky/CentOS Stream/Fedora (Backend1 & Backend2)
sudo dnf -y install nginx
echo "OK $(hostname) — Nginx" | sudo tee /usr/share/nginx/html/index.html
sudo systemctl enable --now nginx
curl -I http://127.0.0.1/
# Arch/Manjaro
sudo pacman -Syu --noconfirm nginx
echo "OK $(hostname) — Nginx" | sudo tee /usr/share/nginx/html/index.html
sudo systemctl enable --now nginx
curl -I http://127.0.0.1/
# openSUSE/SLE
sudo zypper refresh && sudo zypper install -y nginx
echo "OK $(hostname) — Nginx" | sudo tee /srv/www/htdocs/index.html
sudo systemctl enable --now nginx
curl -I http://127.0.0.1/
Option B — Apache HTTP Server (for dynamic apps using .htaccess, etc.)
# Ubuntu/Debian
sudo apt update
sudo apt -y install apache2
echo "OK $(hostname) — Apache" | sudo tee /var/www/html/index.html
sudo systemctl enable --now apache2
curl -I http://127.0.0.1/
# RHEL/Rocky/CentOS Stream/Fedora
sudo dnf -y install httpd
echo "OK $(hostname) — Apache" | sudo tee /var/www/html/index.html
sudo systemctl enable --now httpd
curl -I http://127.0.0.1/
# Arch/Manjaro
sudo pacman -Syu --noconfirm apache
echo "OK $(hostname) — Apache" | sudo tee /srv/http/index.html
sudo systemctl enable --now httpd
curl -I http://127.0.0.1/
# openSUSE/SLE
sudo zypper refresh && sudo zypper install -y apache2
echo "OK $(hostname) — Apache" | sudo tee /srv/www/htdocs/index.html
sudo systemctl enable --now apache2
curl -I http://127.0.0.1/
Reload/Enable & Health Checks
Now test everything end-to-end.
# On LB: check HAProxy and certificate
sudo systemctl status --no-pager haproxy
echo | openssl s_client -servername "$DOMAIN" -connect 127.0.0.1:443 2>/dev/null | openssl x509 -noout -issuer -subject -dates
curl -kI https://127.0.0.1/ # From your laptop or any client on the Internet:
curl -I https://$DOMAIN/
Security / Hardening
Goal: minimize exposure and protect keys.
- Firewall (LB): allow only 80/443 from the Internet, SSH from your admin IPs.
- Permissions: certificate files in
/etc/haproxy/certs/must be readable only by root (mode600). - Auto-renew: acme.sh installs a cron job by default. After renewal, it runs the reload command we set (
systemctl reload haproxy). - Headers: we add HSTS and X-Forwarded-Proto to prevent downgrade/mixed-content issues.
- Logging: check logs when debugging:
- HAProxy:
sudo journalctl -u haproxy -f - acme.sh:
~/.acme.sh/acme.sh.log
- HAProxy:
Firewall commands
# UFW (Ubuntu/Debian) on LB
sudo ufw default deny incoming
sudo ufw allow 22/tcp # or restrict to your IP: sudo ufw allow from 198.51.100.10 to any port 22 proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload
sudo ufw status
# firewalld (RHEL/Rocky/CentOS/Fedora/openSUSE/SLE) on LB
sudo firewall-cmd --permanent --set-default-zone=public
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
# Optional: restrict SSH
# sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="198.51.100.10" service name="ssh" accept'
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
Performance & Optimization
- Enable HTTP/2 (already in the
bindline viaalpn h2). - Set
nbthreadinglobalto number of CPU cores for more throughput. - Start with 30s timeouts; adjust based on app behavior.
Backup & Restore
Backup (LB)
sudo tar -C / -czf /root/haproxy-$(date +%F).tgz etc/haproxy etc/letsencrypt etc/ssl /root/.acme.sh --numeric-owner 2>/dev/null || sudo tar -C / -czf /root/haproxy-$(date +%F).tgz etc/haproxy /root/.acme.sh --numeric-owner
sha256sum /root/haproxy-$(date +%F).tgz
Restore (LB)
BK="/root/haproxy-YYYY-MM-DD.tgz"
sudo systemctl stop haproxy || true
sudo tar -C / -xzf "$BK"
sudo chown -R root:root /etc/haproxy
sudo chmod 600 /etc/haproxy/certs/* 2>/dev/null || true
sudo systemctl start haproxy
sudo systemctl status --no-pager haproxy
Troubleshooting (Top issues)
No certificate issued — DNS not pointing to LB or 80/tcp blocked. Fix DNS; open 80/tcp; re-run with --debug 2.
dig +short $DOMAIN A
curl -I http://$DOMAIN/.well-known/acme-challenge/test
/root/.acme.sh/acme.sh --issue -d "$DOMAIN" --webroot "$ACME_WEBROOT" --debug 2
Backend down — Health checks failing or port mismatch.
sudo journalctl -u haproxy --since "15 min ago" | tail -n 50
curl -I http://{BACKEND1_IP}:{BACKEND_PORT}/
curl -I http://{BACKEND2_IP}:{BACKEND_PORT}/
Mixed content / redirect loops — Force HTTPS and set X-Forwarded-Proto; check app redirects.
Key Takeaways & Next Steps
- HAProxy Load Balancer with Let’s Encrypt centralizes TLS and health checks on the LB.
- acme.sh sets up auto-renew; HAProxy reloads after renewal.
- Next: add sticky sessions, path rules, or a second LB with VRRP for high availability.
