HAProxy es un balanceador de carga y proxy inverso de alto rendimiento. Cuando termina TLS, descifra el tráfico HTTPS en el propio balanceador y reenvía las peticiones en HTTP a los servidores backend, de modo que los certificados se gestionan en un único punto. En este tutorial configurarás HAProxy en Ubuntu 24.04 con certificados de Let's Encrypt renovados automáticamente, varios dominios en el mismo puerto 443 mediante SNI, enrutamiento por ACL y por archivo de mapa, cabeceras de seguridad y un límite de peticiones por IP.

Requisitos previos

  • Un servidor con Ubuntu 24.04 LTS que hará de balanceador, por ejemplo un VPS de CubePath.
  • Un usuario no root con privilegios sudo.
  • Uno o más dominios con registros DNS A apuntando a la IP pública del balanceador. En esta guía se usan your_domain y api.your_domain: sustitúyelos por los tuyos.
  • Al menos un servidor backend con una aplicación HTTP. Los ejemplos usan las IP privadas 10.0.0.11, 10.0.0.12 (web) y 10.0.0.21 (API), todas escuchando en el puerto 8080 y respondiendo en /health.
  • Los puertos 80 y 443 abiertos hacia el balanceador.

Paso 1: Instalar HAProxy

Ubuntu 24.04 incluye HAProxy 2.8, una versión LTS con soporte hasta 2028 y todo lo necesario para esta guía:

sudo apt update
sudo apt install haproxy

Comprueba la versión y que el servicio está activo:

haproxy -v | head -n 1
systemctl status haproxy --no-pager
HAProxy version 2.8.5-1ubuntu3 2024/04/01 - https://haproxy.org/
● haproxy.service - HAProxy Load Balancer
     Loaded: loaded (/usr/lib/systemd/system/haproxy.service; enabled; preset: enabled)
     Active: active (running)

El paquete ya deja configurado rsyslog para escribir los logs de HAProxy en /var/log/haproxy.log.

Si usas UFW, permite el tráfico web:

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

Paso 2: Crear una configuración inicial solo con HTTP

Para emitir los certificados, Let's Encrypt necesita acceder por HTTP a /.well-known/acme-challenge/. En lugar de parar HAProxy en cada renovación, HAProxy reenviará esas rutas a Certbot, que escuchará en 127.0.0.1:8888. Todo lo demás se redirigirá a HTTPS más adelante.

Haz una copia de la configuración original:

sudo cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.orig

Abre el archivo de configuración:

sudo nano /etc/haproxy/haproxy.cfg

Sustituye su contenido por lo siguiente:

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

    # Perfil "intermediate" de Mozilla: TLS 1.2 y 1.3
    ssl-default-bind-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-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

defaults
    log     global
    mode    http
    option  httplog
    option  dontlognull
    option  forwardfor
    timeout connect 5s
    timeout client  30s
    timeout server  30s

frontend http_in
    bind :80
    acl is_acme path_beg /.well-known/acme-challenge/
    use_backend be_acme if is_acme
    default_backend be_web

backend be_acme
    server certbot 127.0.0.1:8888

backend be_web
    balance roundrobin
    option httpchk
    http-check send meth GET uri /health ver HTTP/1.1 hdr Host your_domain
    server web1 10.0.0.11:8080 check
    server web2 10.0.0.12:8080 check

backend be_api
    balance roundrobin
    option httpchk
    http-check send meth GET uri /health ver HTTP/1.1 hdr Host api.your_domain
    server api1 10.0.0.21:8080 check

Qué hace cada bloque:

  • global: proceso, socket de administración (lo usarás en el paso 7) y parámetros TLS por defecto para todos los bind ... ssl.
  • defaults: modo HTTP, logs detallados y la cabecera X-Forwarded-For para que los backends vean la IP real del cliente.
  • be_acme: backend sin comprobación de salud; solo recibe tráfico mientras Certbot está validando.
  • be_web y be_api: backends con comprobación de salud HTTP contra /health.

Valida la sintaxis y recarga el servicio:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
Configuration file is valid

Paso 3: Obtener los certificados con Certbot

Instala Certbot:

sudo apt install certbot

Solicita un certificado por dominio. Certbot levanta un servidor temporal en 127.0.0.1:8888 y HAProxy le pasa el desafío:

sudo certbot certonly --standalone \
  --http-01-address 127.0.0.1 --http-01-port 8888 \
  -d your_domain -d www.your_domain
sudo certbot certonly --standalone \
  --http-01-address 127.0.0.1 --http-01-port 8888 \
  -d api.your_domain

Certbot guarda cada certificado en /etc/letsencrypt/live/<dominio>/ y recuerda estas opciones para las renovaciones automáticas:

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/your_domain/fullchain.pem
Key is saved at:         /etc/letsencrypt/live/your_domain/privkey.pem

Paso 4: Preparar los certificados para HAProxy

HAProxy espera un único archivo PEM con la cadena completa y la clave privada. Un hook de despliegue de Certbot genera ese archivo en cada emisión o renovación y recarga HAProxy.

Crea el directorio de certificados, legible solo por root:

sudo install -d -m 700 /etc/haproxy/certs

Crea el hook:

sudo nano /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
#!/usr/bin/env bash
# Combina fullchain + clave para HAProxy y recarga el servicio.
set -euo pipefail

domain="$(basename "$RENEWED_LINEAGE")"
target="/etc/haproxy/certs/${domain}.pem"

umask 077
cat "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" > "${target}.tmp"
mv "${target}.tmp" "$target"

systemctl reload haproxy

Hazlo ejecutable y ejecútalo una vez por cada certificado ya emitido, pasando la variable que Certbot define en las renovaciones:

sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/your_domain /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/api.your_domain /etc/letsencrypt/renewal-hooks/deploy/haproxy.sh
sudo ls -l /etc/haproxy/certs/
-rw------- 1 root root 5412 Sep 25 10:12 api.your_domain.pem
-rw------- 1 root root 5420 Sep 25 10:12 your_domain.pem

HAProxy lee los certificados como root antes de soltar privilegios, así que el permiso 600 es suficiente.

Paso 5: Activar HTTPS con varios certificados (SNI)

Si en crt indicas un directorio en lugar de un archivo, HAProxy carga todos los PEM que contiene y elige el correcto según el nombre que pide el cliente (SNI). Añadir un dominio nuevo solo requiere dejar su PEM en /etc/haproxy/certs/ y recargar.

Abre de nuevo la configuración:

sudo nano /etc/haproxy/haproxy.cfg

Sustituye el bloque frontend http_in completo por estos dos frontends:

frontend http_in
    bind :80
    acl is_acme path_beg /.well-known/acme-challenge/
    http-request redirect scheme https code 301 unless is_acme
    use_backend be_acme if is_acme

frontend https_in
    bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1

    # Informa al backend de que la conexión original era HTTPS
    http-request set-header X-Forwarded-Proto https

    # Cabeceras de seguridad en todas las respuestas
    http-response set-header Strict-Transport-Security "max-age=31536000"
    http-response set-header X-Content-Type-Options nosniff

    use_backend be_api if { req.hdr(host) -i api.your_domain }
    default_backend be_web
  • alpn h2,http/1.1 negocia HTTP/2 con los clientes que lo soportan; hacia los backends se sigue usando HTTP/1.1.
  • La redirección a HTTPS excluye el desafío ACME para que las renovaciones sigan funcionando.
  • Strict-Transport-Security indica al navegador que use siempre HTTPS. Actívalo solo cuando todo el dominio funcione bien por HTTPS.

Valida y recarga:

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

Comprueba desde tu equipo que la redirección y el certificado funcionan:

curl -I http://your_domain
curl -I https://your_domain
HTTP/1.1 301 Moved Permanently
location: https://your_domain/

HTTP/2 200
strict-transport-security: max-age=31536000
x-content-type-options: nosniff

Verifica que cada dominio recibe su propio certificado:

openssl s_client -connect your_domain:443 -servername api.your_domain </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -dates
subject=CN = api.your_domain
notBefore=Sep 25 09:10:00 2026 GMT
notAfter=Dec 24 09:09:59 2026 GMT

Paso 6: Enrutar con ACL y archivos de mapa

Las ACL permiten decidir el backend según cualquier propiedad de la petición. Las reglas use_backend se evalúan en orden y gana la primera que coincide.

Un ejemplo habitual es enviar /api/ al backend de API independientemente del dominio, y proteger una ruta de administración por IP. Añade estas líneas en frontend https_in, justo antes de las líneas use_backend existentes:

    acl is_admin path_beg /admin
    acl from_office src 203.0.113.0/24
    http-request deny deny_status 403 if is_admin !from_office

    use_backend be_api if { path_beg /api/ }

Sustituye 203.0.113.0/24 por la red desde la que se administra la aplicación.

Cuando el número de dominios crece, una lista de ACL se vuelve difícil de mantener. Un archivo de mapa asocia cada Host con un backend. Créalo:

sudo install -d /etc/haproxy/maps
sudo nano /etc/haproxy/maps/hosts.map
your_domain        be_web
www.your_domain    be_web
api.your_domain    be_api

En frontend https_in, sustituye las dos últimas líneas (use_backend be_api if { req.hdr(host) ... } y default_backend be_web) por:

    use_backend %[req.hdr(host),lower,map_str(/etc/haproxy/maps/hosts.map,be_web)]

El segundo argumento de map_str es el backend por defecto cuando el dominio no aparece en el mapa. Valida, recarga y prueba el enrutamiento:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
curl -s -o /dev/null -w '%{http_code}\n' https://api.your_domain/health
curl -s -o /dev/null -w '%{http_code}\n' https://your_domain/admin
200
403

Paso 7: Limitar la tasa de peticiones por IP

Una stick table guarda contadores por clave, en este caso la IP de origen. Con ella puedes rechazar a los clientes que superan un umbral de peticiones. Añade estas líneas al principio de frontend https_in, justo después de la línea bind:

    stick-table type ip size 100k expire 30s store http_req_rate(10s)
    http-request track-sc0 src
    http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }

Cada IP puede hacer hasta 100 peticiones en una ventana deslizante de 10 segundos; a partir de ahí recibe 429 Too Many Requests. Ajusta el umbral a tu tráfico real: detrás de un NAT corporativo muchos usuarios comparten IP.

Valida y recarga:

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

Consulta el contenido de la tabla a través del socket de administración. Instala socat primero:

sudo apt install socat
echo "show table https_in" | sudo socat stdio /run/haproxy/admin.sock
# table: https_in, type: ip, size:102400, used:1
0x55d0c8a1b2c0: key=198.51.100.7 use=0 exp=28512 http_req_rate(10000)=12

El mismo socket permite cambiar entradas del mapa sin recargar. El cambio solo vive en memoria, así que añádelo también a hosts.map para que sobreviva a un reinicio:

echo "add map /etc/haproxy/maps/hosts.map blog.your_domain be_web" | sudo socat stdio /run/haproxy/admin.sock
echo "show map /etc/haproxy/maps/hosts.map" | sudo socat stdio /run/haproxy/admin.sock

Paso 8: Consultar la página de estadísticas

La página de estadísticas muestra el estado de cada backend y servidor. Publícala solo en localhost y accede mediante un túnel SSH. Añade al final de /etc/haproxy/haproxy.cfg:

frontend stats
    bind 127.0.0.1:8404
    stats enable
    stats uri /stats
    stats refresh 10s

Valida y recarga la configuración. Después, desde tu equipo, abre un túnel:

ssh -L 8404:127.0.0.1:8404 your_user@your_server_ip

Con el túnel abierto, visita http://localhost:8404/stats en el navegador. Los servidores en verde están pasando la comprobación de salud.

Paso 9: Comprobar la renovación automática

El paquete de Certbot instala un temporizador de systemd que intenta renovar dos veces al día. Haz una prueba en seco para confirmar que el desafío llega a Certbot a través de HAProxy:

sudo certbot renew --dry-run
systemctl list-timers certbot.timer --no-pager
Congratulations, all simulated renewals succeeded:
  /etc/letsencrypt/live/api.your_domain/fullchain.pem (success)
  /etc/letsencrypt/live/your_domain/fullchain.pem (success)

En las renovaciones reales, el hook del paso 4 regenerará el PEM y recargará HAProxy sin cortar conexiones.

Solución de problemas

  • unable to stat SSL certificate from file o no certificate found: el directorio /etc/haproxy/certs/ está vacío o un PEM no contiene la clave. Revisa con sudo openssl x509 -in /etc/haproxy/certs/your_domain.pem -noout -subject.
  • Certbot falla con Connection refused o Timeout during connect: comprueba que el DNS apunta al balanceador, que el puerto 80 está abierto y que la redirección a HTTPS contiene unless is_acme.
  • Todos los servidores aparecen como DOWN: la comprobación de salud no recibe un 2xx o 3xx. Prueba desde el balanceador con curl -i -H "Host: your_domain" http://10.0.0.11:8080/health.
  • La aplicación genera enlaces http://: configúrala para confiar en la cabecera X-Forwarded-Proto que envía HAProxy.
  • Revisar qué ocurre: sudo tail -f /var/log/haproxy.log muestra cada petición con el frontend, el backend y el servidor que la atendió.

Conclusión

HAProxy ya termina TLS para varios dominios con certificados de Let's Encrypt que se renuevan solos, redirige HTTP a HTTPS, reparte el tráfico según el dominio y la ruta y frena a los clientes que abusan. Como siguientes pasos, puedes afinar las comprobaciones de salud y la conmutación por error de los backends, cifrar también el tramo hasta los backends con ssl verify required ca-file en las líneas server, o poner dos balanceadores en alta disponibilidad con keepalived y una IP flotante.