Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
Redes & Segurança · By Kevin Marcondes · Published on · 8 min read
This is a step-by-step tutorial to configure HAProxy with Let's Encrypt and health checks for load balancing traffic between Docker containers on a VPS. You'll build the architecture, obtain certificates via ACME HTTP-01, enable HTTPS, monitor backend health, and automate renewal. The walkthrough uses Docker Compose and ready-to-use files you can adapt to your domain.
Overview and prerequisites
We'll create an HAProxy load balancer in Docker that terminates TLS and distributes requests across two application containers. Certificate issuance uses the HTTP-01 challenge, served by a dedicated container that answers the validation path.
Practical prerequisites:
- A Linux VPS with root or sudo access. Open TCP ports 80 and 443 in the firewall. This exposes the service publicly.
- A domain name pointed to the VPS (A/AAAA records). DNS propagation may take time.
- Docker and Docker Compose installed. Follow this guide if you need to install Docker and Docker Compose.
- Firewall configured to allow 80/tcp and 443/tcp. See: UFW firewall on Ubuntu.
- Basic VPS security best practices: SSH hardening, updates and backups. Check the VPS security checklist.
Architecture and ports (what we'll run)
The solution includes four services: haproxy (ports 80/443), acme (a static server for the HTTP-01 challenge), certbot (issuance/renewal helper) and two example apps. All services share a Docker network so they can reference each other by name.
Traffic flow:
- HTTP 80: serves /.well-known/acme-challenge/ (ACME)
- HTTP 80: redirects all other requests to HTTPS
- HTTPS 443: main site frontend
- Backends: app1 and app2 on 8080 with health checks
Project Compose: services, network and volumes
Create a project directory, save the file below as docker-compose.yml and adjust as needed. It defines HAProxy, a static server for the ACME challenge, the certbot container and two example apps. Don't forget to set your domain inside haproxy.cfg.
version: "3.8"
services:
haproxy:
image: haproxy:2.9-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
- letsencrypt:/etc/letsencrypt:ro
depends_on:
- app1
- app2
- acme
networks:
- edge
restart: unless-stopped
command: ["haproxy","-W","-db","-f","/usr/local/etc/haproxy/haproxy.cfg"]
# Servidor estático que entrega o desafio HTTP-01 do Let's Encrypt
acme:
image: nginx:alpine
networks:
- edge
volumes:
- acme-challenge:/usr/share/nginx/html:ro
restart: unless-stopped
# Container utilitário para emitir/renovar certificados
certbot:
image: certbot/certbot:latest
networks:
- edge
volumes:
- letsencrypt:/etc/letsencrypt
- acme-challenge:/var/www/certbot
entrypoint: ["sh","-c"]
command: "echo 'Use: docker compose run --rm certbot ...' && sleep infinity"
restart: unless-stopped
# Apps de exemplo (substitua pelos seus serviços)
app1:
image: hashicorp/http-echo:1.0
command: ["-text=app1","-listen=:8080"]
networks:
- edge
restart: unless-stopped
app2:
image: hashicorp/http-echo:1.0
command: ["-text=app2","-listen=:8080"]
networks:
- edge
restart: unless-stopped
networks:
edge:
volumes:
letsencrypt:
acme-challenge:
Configure HAProxy: HTTPS, redirect and health checks
Create haproxy.cfg next to docker-compose.yml. Replace EXAMPLE.COM with your domain. We use a backend for the ACME challenge, an HTTP→HTTPS redirect for non-ACME requests, and health checks (/healthz) on the app backends. The haproxy.pem file is created after the certificate is issued.
Tip for first issuance: if haproxy.pem does not exist yet, temporarily comment out the HTTPS frontend (port 443), run only with port 80 active to serve the HTTP‑01 challenge, then generate the certificate, re-enable port 443 and recreate the container.
global
log stdout format raw local0
maxconn 2000
tune.ssl.default-dh-param 2048
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
retries 2
frontend fe_http
bind *:80
acl is_acme path_beg /.well-known/acme-challenge/
use_backend be_acme if is_acme
http-request redirect scheme https code 301 unless is_acme
frontend fe_https
bind *:443 ssl crt /etc/letsencrypt/live/EXEMPLO.COM/haproxy.pem
http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
default_backend be_apps
backend be_acme
server acme acme:80 check
backend be_apps
balance roundrobin
option httpchk GET /healthz
http-check expect status 200
server app1 app1:8080 check inter 2s fall 3 rise 2
server app2 app2:8080 check inter 2s fall 3 rise 2
Issue certificates with Let's Encrypt (ACME HTTP-01)
With HAProxy (port 80 active) and the acme container running, issue the certificate using a webroot approach. Then create haproxy.pem by concatenating fullchain.pem and privkey.pem in the shared volume. Run these commands as a user with permission to control Docker. To learn more about ACME/LE, see how to get free SSL with Let's Encrypt.
# 1) Ajuste seu domínio e e-mail
export DOMAIN=seu-dominio.com.br
export [email protected]
# 2) Suba o servidor do desafio ACME e o HAProxy (porta 80 ativa)
# Se for a primeira emissão, deixe a seção HTTPS comentada no haproxy.cfg
docker compose up -d acme haproxy
# 3) Emita o certificado (HTTP-01 via webroot)
docker compose run --rm certbot certonly \
--webroot -w /var/www/certbot \
-d "$DOMAIN" --email "$EMAIL" --agree-tos --no-eff-email
# 4) Gere o PEM combinado esperado pelo HAProxy (no volume LE)
docker compose run --rm certbot sh -lc \
"cat /etc/letsencrypt/live/$DOMAIN/fullchain.pem \
/etc/letsencrypt/live/$DOMAIN/privkey.pem \
> /etc/letsencrypt/live/$DOMAIN/haproxy.pem"
# 5) Recrie o HAProxy para carregar o certificado (reative o 443 se estava comentado)
docker compose up -d --no-deps --force-recreate haproxy
Verify load balancing and health checks
Test HTTPS, load balancing and automatic removal of nodes from the pool. You should see responses alternating between app1 and app2. If ports 80/443 are blocked by a firewall or DNS does not resolve to the VPS, issuance and tests will fail.
# Teste o certificado e o redirecionamento
curl -I http://$DOMAIN
curl -skI https://$DOMAIN
# Observe alternância entre backends
for i in {1..6}; do curl -sk https://$DOMAIN/; done
# Simule falha de um backend e confira alta disponibilidade
docker compose stop app1
for i in {1..4}; do curl -sk https://$DOMAIN/; done
# Volte com o backend e veja o pool normalizar
docker compose up -d app1
Automatic renewal with systemd (optional)
Automate renewal with systemd timers on the host. The service runs certbot renew, rebuilds haproxy.pem and recreates the HAProxy container to apply the new certificate. Renewal only performs actions when certificates are near expiry; running daily is safe. Adjust the WorkingDirectory path to match your installation.
# /etc/systemd/system/letsencrypt-renew.service
[Unit]
Description=Renew Let's Encrypt certs via Docker Compose
Wants=network-online.target
After=network-online.target
WorkingDirectory=/opt/edge
[Service]
Type=oneshot
ExecStart=/usr/bin/docker compose -f docker-compose.yml run --rm certbot renew --webroot -w /var/www/certbot
ExecStartPost=/usr/bin/docker compose -f docker-compose.yml run --rm certbot sh -lc 'cat /etc/letsencrypt/live/seu-dominio.com.br/fullchain.pem /etc/letsencrypt/live/seu-dominio.com.br/privkey.pem > /etc/letsencrypt/live/seu-dominio.com.br/haproxy.pem'
ExecStartPost=/usr/bin/docker compose -f docker-compose.yml up -d --no-deps --force-recreate haproxy
# /etc/systemd/system/letsencrypt-renew.timer
[Unit]
Description=Run LE renew twice daily
[Timer]
OnCalendar=*-*-* 03,15:00:00
Persistent=true
[Install]
WantedBy=timers.target
Common troubleshooting
If something fails, check these points before diving into complex debugging. Many issuance and balancing issues are caused by DNS, firewall, or incorrect service names.
- DNS does not point to the VPS or hasn't propagated yet. Confirm with dig or nslookup.
- Firewall / cloud provider blocking 80/443. Open the public ports and test from outside the VPS.
- Certificate path in haproxy.cfg doesn't match your domain.
- Compose didn't create the network or service names differ (app1/app2/acme).
- Certificate was generated but HAProxy wasn't recreated. Reapply the container.
- Server clock out of sync. Enable NTP to avoid ACME failures.
Frequently asked questions
Can I use multiple domains with the same HAProxy?
Yes. Issue certificates for each domain and create a haproxy.pem for each one. You can point the bind to a directory with multiple .pem files and rely on SNI. Reload HAProxy after adding a new certificate.
Is IPv6 supported with this setup?
Yes. Add an AAAA DNS record for the domain and ensure ports 80 and 443 are open for IPv6 on the server. HAProxy can listen on both IPv4 and IPv6 if you configure the binds accordingly.
How do I minimize downtime when reloading HAProxy?
Run HAProxy in master-worker mode (-W) and perform controlled reloads. In Docker, recreating the service is straightforward and usually fast. For zero downtime, consider blue-green techniques: see blue-green deploy.
Can I use DNS-01 instead of HTTP-01?
Yes, if your DNS provider offers an API. Replace the webroot flow with a certbot DNS plugin. This lets you issue certificates without exposing port 80, but requires provider credentials and specific configuration.
Where do I configure routing by path or host?
In the HTTPS frontend you can use ACLs such as hdr(host) and path_beg to route by domain and path. Create dedicated backends and use use_backend based on those ACLs. This approach scales to many services.
Need to adapt this setup to your environment?
This guide covers the essentials for HAProxy, Let's Encrypt and health checks in Docker. If you want help tailoring it to your application, stack and domain, get in touch.
Discuss your architectureSee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian
- Automate LVM snapshots and incremental Restic uploads to S3 with systemd timers on Debian