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.