Skip to content
Go back

HAProxy: Load Balancing Done Right

By SumGuy 8 min read
HAProxy: Load Balancing Done Right

The Load Balancer You Keep Ignoring

You’ve probably heard of HAProxy. Maybe you’ve seen it mentioned in a stack overflow thread, or noticed it in the config of some open source project. Then you clicked through to the documentation, saw phrases like “nbthread”, “tune.ssl.default-dh-param”, and “maxconnrate” and quietly closed the tab.

Here’s the thing: HAProxy has a reputation for complexity that it doesn’t entirely deserve. Yes, it has 500+ config options. No, you don’t need to understand them. The 20 options you’ll actually use are straightforward, and what you get in return is a load balancer that runs GitHub, Reddit, Instagram, and basically half the internet at scale.

Let’s demystify it.

What HAProxy Actually Is

HAProxy (High Availability Proxy) is a battle-tested TCP and HTTP load balancer and proxy server that’s been around since 2000. It operates at both Layer 4 (TCP) and Layer 7 (HTTP), which means it can route raw TCP connections and make smart routing decisions based on HTTP headers, URLs, and cookies.

Why reach for HAProxy instead of nginx or Traefik?

Nginx can load balance. Traefik can load balance. But when you need serious control over how traffic flows — HAProxy is the tool that was built specifically for this job.

Config Structure: Four Sections, That’s It

HAProxy configs have four sections. Once you know what each one does, the rest is just filling in blanks.

haproxy.cfg
global
# Process-level settings: logging, limits, SSL
defaults
# Defaults for all frontends and backends that follow
frontend my_frontend
# Where traffic comes IN: ports, ACLs, routing decisions
backend my_backend
# Where traffic goes OUT: server list, balancing algo, health checks

That’s the whole mental model. Traffic enters through a frontend, gets routed by ACLs, and lands on a backend where your actual servers live.

A Real Config, Explained

Here’s a complete HAProxy config for 3 app containers with health checks, a stats page, and HTTP routing:

haproxy.cfg
global
log stdout format raw local0
maxconn 50000
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
#---------------------------------------------------------------------
# Stats page — keep this locked down or firewall it
#---------------------------------------------------------------------
frontend stats
bind *:8404
stats enable
stats uri /stats
stats refresh 10s
stats auth admin:supersecretpassword
stats admin if TRUE
#---------------------------------------------------------------------
# Main HTTP frontend
#---------------------------------------------------------------------
frontend http_in
bind *:80
# ACL: route /api/* to the API backend
acl is_api path_beg /api/
use_backend api_servers if is_api
default_backend app_servers
#---------------------------------------------------------------------
# App backend: round-robin across 3 containers
#---------------------------------------------------------------------
backend app_servers
balance roundrobin
option httpchk GET /health
http-check expect status 200
server app1 app1:3000 check inter 5s rise 2 fall 3
server app2 app2:3000 check inter 5s rise 2 fall 3
server app3 app3:3000 check inter 5s rise 2 fall 3
#---------------------------------------------------------------------
# API backend: least connections
#---------------------------------------------------------------------
backend api_servers
balance leastconn
option httpchk GET /api/health
http-check expect status 200
server api1 api1:4000 check inter 5s rise 2 fall 3
server api2 api2:4000 check inter 5s rise 2 fall 3

Let’s break down the interesting bits.

Frontends: Where Traffic Enters

The frontend is your entry point. bind *:80 listens on all interfaces on port 80. You can bind multiple ports, add SSL here, or split traffic by port.

ACLs are the routing brain. They test conditions and let you make use_backend decisions:

haproxy.cfg
# Match by path prefix
acl is_api path_beg /api/
# Match by hostname
acl is_blog hdr(host) -i blog.example.com
# Match by request method
acl is_post method POST
# Combine conditions
use_backend heavy_backend if is_api is_post

ACLs are evaluated top to bottom. The first matching use_backend wins. If nothing matches, default_backend catches it.

Backends: Where Traffic Goes

The backend defines your server pool and how HAProxy distributes load.

Balancing algorithms:

For most web apps, roundrobin is fine. Use leastconn when requests have wildly different durations (file uploads, video transcoding, etc.).

Health Checks: The Part That Actually Matters

This is where HAProxy earns its keep. Health checks are what let you pull a container out of rotation without downtime.

haproxy.cfg
server app1 app1:3000 check inter 5s rise 2 fall 3

The rise/fall thresholds prevent flapping. A server that’s struggling but not fully dead won’t get pulled at the first hiccup, and won’t get added back until it’s actually stable.

For HTTP checks:

haproxy.cfg
option httpchk GET /health
http-check expect status 200

HAProxy sends a real HTTP request and checks the response code. Your /health endpoint can do whatever you want — check DB connectivity, cache availability, whatever signals “I’m ready.”

For TCP-only services (Redis, Postgres, etc.):

haproxy.cfg
backend postgres_backend
mode tcp
balance leastconn
option tcp-check
server db1 db1:5432 check inter 10s rise 2 fall 3

Sticky Sessions

Round-robin means each request can land on a different server. That’s usually fine, but some apps store session state in memory. If user A’s session is on app2 and their next request hits app3, things break.

The fix is a cookie-based sticky session:

haproxy.cfg
backend app_servers
balance roundrobin
cookie SERVERID insert indirect nocache
server app1 app1:3000 check cookie app1
server app2 app2:3000 check cookie app2
server app3 app3:3000 check cookie app3

HAProxy injects a SERVERID cookie on the first response. Subsequent requests from that client carry the cookie, and HAProxy routes them to the same backend. When the backend is down, HAProxy ignores the cookie and picks another server.

Honest advice: if you’re building something new, store sessions in Redis and skip sticky sessions entirely. But if you’re load balancing a legacy app you can’t modify, this saves you.

SSL Termination

HAProxy can terminate SSL so your backends receive plain HTTP:

haproxy.cfg
frontend https_in
bind *:443 ssl crt /etc/haproxy/certs/example.com.pem
default_backend app_servers

The .pem file should be your certificate + private key concatenated. HAProxy handles the TLS handshake; backends see unencrypted HTTP.

For SSL passthrough (HAProxy doesn’t decrypt, just routes):

haproxy.cfg
frontend https_passthrough
bind *:443
mode tcp
default_backend ssl_backends
backend ssl_backends
mode tcp
server app1 app1:443 check

Use passthrough when backends handle their own TLS (mutual auth, client certs, etc.) or when you have compliance requirements about where decryption happens.

Docker Compose Setup

Here’s the full Docker Compose for the 3-app setup:

docker-compose.yml
services:
haproxy:
image: haproxy:3.0-alpine
ports:
- "80:80"
- "8404:8404"
volumes:
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
- app1
- app2
- app3
app1:
image: your-app:latest
environment:
- NODE_ENV=production
app2:
image: your-app:latest
environment:
- NODE_ENV=production
app3:
image: your-app:latest
environment:
- NODE_ENV=production

Bring it up:

Terminal window
docker compose up -d

Then hit http://localhost:8404/stats with the credentials you set. You’ll see a live dashboard showing request rates, response times, server status, and health check results for every backend. It’s genuinely useful — not just for debugging but for understanding your traffic patterns.

Reload Without Downtime

When you update haproxy.cfg, you don’t restart HAProxy — you reload it. Existing connections finish normally while new connections use the updated config:

Terminal window
# Validate config first
docker compose exec haproxy haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg
# Reload without downtime
docker compose kill -s HUP haproxy

The -c flag is a dry run. Always run it before reloading. HAProxy will tell you exactly what’s wrong if the config has errors, which is more than can be said for some other tools.

The 20 Options You Actually Need

Here’s what you just learned to use:

That’s it. That’s the HAProxy you’ll use 95% of the time. The other 480 options exist for edge cases you’ll know you have when you have them.

HAProxy’s reputation for complexity is mostly documentation shock — the manual is exhaustive because it covers everything. But a working config for a real service fits in 50 lines. Now you have one.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
The Zero-Trust Home Lab
Next Post
Stop Living Dangerously on :latest Docker

Related Posts