Install on Debian / Ubuntu
# The distro package is usually 2.x; for current 3.x, use the HAProxy.com PPA
curl -fsSL https://haproxy.debian.net/bernat.debian.org.gpg \
| sudo gpg --dearmor -o /usr/share/keyrings/haproxy.debian.net.gpg
echo "deb [signed-by=/usr/share/keyrings/haproxy.debian.net.gpg] http://haproxy.debian.net $(lsb_release -cs)-backports-3.0 main" \
| sudo tee /etc/apt/sources.list.d/haproxy.list
sudo apt update
sudo apt install haproxy=3.0.\*
# Or stick with the distro version for an LTS box
sudo apt install haproxy
Anatomy of haproxy.cfg
One config file under /etc/haproxy/haproxy.cfg with four section types:
- global — process-wide settings (user/group, max-connections, default TLS ciphers, runtime socket path).
- defaults — default values for frontends and backends below it.
- frontend — listens on a port, applies ACLs, routes to backends.
- backend — pool of servers; load-balancing algorithm; health checks.
A working HTTPS load balancer
# /etc/haproxy/haproxy.cfg
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
# TLS defaults — modern ciphers only
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-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
mode http
log global
option httplog
option dontlognull
option forwardfor
option http-server-close
timeout connect 5s
timeout client 60s
timeout server 60s
frontend http_in
bind *:80
# Redirect every HTTP request to HTTPS
http-request redirect scheme https code 301
frontend https_in
bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
bind *:443 quic4 ssl crt /etc/haproxy/certs/ alpn h3 # HTTP/3 / QUIC (HAProxy 2.6+)
# Useful defaults
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-Port %[dst_port]
# Route by hostname
acl is_api hdr(host) -i api.example.com
acl is_web hdr(host) -i www.example.com example.com
use_backend api_servers if is_api
use_backend web_servers if is_web
default_backend web_servers
backend api_servers
balance roundrobin
option httpchk GET /health
http-check expect status 200
server api1 10.0.1.10:8080 check inter 2s fall 3 rise 2
server api2 10.0.1.11:8080 check inter 2s fall 3 rise 2
server api3 10.0.1.12:8080 check inter 2s fall 3 rise 2
backend web_servers
balance leastconn
cookie SERVERID insert indirect nocache
option httpchk GET /healthz
server web1 10.0.2.10:80 check cookie web1
server web2 10.0.2.11:80 check cookie web2
listen stats
bind *:8404
stats enable
stats uri /stats
stats refresh 10s
stats admin if LOCALHOST
The certs directory
HAProxy reads PEM files from /etc/haproxy/certs/ — each file contains the full chain followed by the private key, all in one .pem. Multiple files = SNI: HAProxy picks the right cert per Host header.
cat fullchain.pem privkey.pem > /etc/haproxy/certs/example.com.pem
chmod 600 /etc/haproxy/certs/example.com.pem
chown haproxy:haproxy /etc/haproxy/certs/example.com.pem
For Let's Encrypt: certbot's deploy hook runs after each renewal — reconcat the chain + key, drop into the certs dir, run systemctl reload haproxy.
Load-balancing algorithms
roundrobin— default; rotate through backends. Best for stateless / equal-capacity backends.leastconn— pick the backend with the fewest active connections. Best for long-lived connections or unequal request costs.source— hash by client IP. Pins users to a backend without cookies.uri— hash by request URI. Useful for cache servers (same URL always goes to the same backend).hdr(name)— hash by an HTTP header value.random— pseudo-random pick. Works surprisingly well at high concurrency.
Sticky sessions
For apps with server-side session state, sticky sessions pin a user to a backend. HAProxy can use cookies, URI hashing, or source IP hashing:
# Cookie-based: HAProxy adds a cookie naming the backend; subsequent requests
# carrying the cookie go to that backend
backend web_servers
cookie SERVERID insert indirect nocache
server web1 10.0.2.10:80 cookie web1 check
server web2 10.0.2.11:80 cookie web2 check
insert indirect nocache matters: the cookie is added on response but stripped from upstream requests so backend apps don't see it; and downstream caches are told not to cache the response.
Health checks
option httpchk + a path tells HAProxy to send a check request periodically. Backends that fail successive checks are marked DOWN; healthy ones are marked UP. Per-server tunables:
server api1 10.0.1.10:8080 \
check \
inter 2s # interval between checks
fastinter 1s # interval when state is transitioning
fall 3 # consecutive failures to mark DOWN
rise 2 # consecutive successes to mark back UP
maxconn 200 # max concurrent connections to this backend
Backends transition gradually; a single failed request doesn't yank the server out of the pool unless fall 1 is set.
Rate limiting
frontend https_in
bind *:443 ssl crt /etc/haproxy/certs/
stick-table type ip size 100k expire 1m store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
# Allow up to 100 reqs per 10s per source IP, then 429
The runtime API
HAProxy exposes a Unix socket where you can reconfigure the running daemon without restarting:
echo "show servers state" | sudo socat /run/haproxy/admin.sock -
# Mark a server DOWN administratively for maintenance
echo "disable server web_servers/web1" | sudo socat /run/haproxy/admin.sock -
# After deploy, mark it back UP
echo "enable server web_servers/web1" | sudo socat /run/haproxy/admin.sock -
# Drain a server (existing connections finish; no new ones)
echo "set server web_servers/web1 state drain" | sudo socat /run/haproxy/admin.sock -
Combined with deploy automation, this turns rolling deployments into a clean drain-deploy-add-back loop.
Validate before reload
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
# If it returns "Configuration file is valid", reload:
sudo systemctl reload haproxy
Reload is a graceful, zero-dropped-connections operation: HAProxy forks a new process with the new config and keeps the old one alive until existing connections close.
L4 TCP load balancing
For protocols that aren't HTTP (Postgres, MySQL, SMTP, RDP), use mode tcp:
frontend postgres_in
bind *:5432
mode tcp
default_backend postgres_pool
backend postgres_pool
mode tcp
option pgsql-check user healthcheck
balance leastconn
server pg1 10.0.3.10:5432 check
server pg2 10.0.3.11:5432 check backup
backup on the second server makes it a hot standby; HAProxy only routes there if every non-backup server is down.
HAProxy vs alternatives
- nginx — can also load-balance HTTP; configuration is simpler for the common cases; more limited at the LB feature ceiling.
- Caddy (see that tutorial) — great for reverse proxy + automatic HTTPS at small / medium scale; less mature load-balancer feature set.
- Traefik — dynamic config from labels (Docker, K8s ingress); pick for container-native workflows.
- Cloud LBs (AWS ALB/NLB, GCP LB) — managed; no operations; pay per request; less flexibility.
For "a serious LB in front of an app fleet, on hardware or VMs you own," HAProxy is the most boring (in the good sense) tool available. Twenty-plus years of production track record and it still keeps shipping new features.