The problem: one server can't keep up, multiple servers need coordination
When an app starts struggling under load, the simplest fix is horizontal scaling: run multiple instances at once. But immediately you hit the next problem:
- Where do clients connect?
- If one instance dies, requests still going to it will fail
- How do you know which instance is healthy?
This is when you need a load balancer in front of your backends to receive requests and distribute them.
HAProxy handles exactly this. It's lightweight, fast, clearly configurable, and has been used in large production deployments for over twenty years.
What load balancing is, briefly
Load balancing distributes incoming requests across multiple backend servers rather than sending everything to one place.
Client
│
▼
Load Balancer (HAProxy)
├── Backend 1 (app:8001)
├── Backend 2 (app:8002)
└── Backend 3 (app:8003)
HAProxy receives all requests on its listening port, then decides which backend to forward each request to based on the configured algorithm.
The three most common algorithms:
| Algorithm | How it works | Use when |
|---|---|---|
roundrobin |
Rotate through servers in order | Lightweight, similar requests |
leastconn |
Send to the server with fewest connections | Heavy requests, long processing |
source |
Same client IP → same backend | Session stickiness needed |
Prerequisites
- Docker and Docker Compose installed
- Basic understanding of Docker networking
- An app to test against (this guide uses Nginx as a mock backend)
Project structure
haproxy-demo/
├── docker-compose.yml
├── haproxy/
│ └── haproxy.cfg
Step 1: Write the HAProxy configuration
Create haproxy/haproxy.cfg:
global
log stdout format raw local0
maxconn 4096
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5s
timeout client 30s
timeout server 30s
frontend http_front
bind *:80
default_backend web_backends
backend web_backends
balance roundrobin
option httpchk GET /
http-check expect status 200
server backend1 app1:80 check inter 5s fall 3 rise 2
server backend2 app2:80 check inter 5s fall 3 rise 2
server backend3 app3:80 check inter 5s fall 3 rise 2
listen stats
bind *:8404
stats enable
stats uri /stats
stats refresh 5s
stats show-node
stats auth admin:admin123
Key parts explained:
frontend http_front: HAProxy listens on port 80, receives all HTTP requestsbackend web_backends: defines the backend group and distribution methodbalance roundrobin: rotates evenly across 3 serversoption httpchk GET /: health check by sending GET to/check inter 5s fall 3 rise 2: checks every 5 seconds, marks down after 3 consecutive failures, marks up after 2 consecutive successeslisten stats: Stats UI on port 8404, protected by basic auth
Step 2: Write docker-compose.yml
services:
haproxy:
image: haproxy:2.9-alpine
ports:
- "80:80"
- "8404:8404"
volumes:
- ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on:
- app1
- app2
- app3
restart: unless-stopped
app1:
image: nginx:alpine
volumes:
- ./app1:/usr/share/nginx/html:ro
app2:
image: nginx:alpine
volumes:
- ./app2:/usr/share/nginx/html:ro
app3:
image: nginx:alpine
volumes:
- ./app3:/usr/share/nginx/html:ro
Step 3: Create mock backend content
mkdir -p app1 app2 app3
echo "<h1>Backend 1</h1>" > app1/index.html
echo "<h1>Backend 2</h1>" > app2/index.html
echo "<h1>Backend 3</h1>" > app3/index.html
Step 4: Start the stack
docker compose up -d
docker compose ps
Expected output:
NAME STATUS PORTS
haproxy-demo-haproxy-1 Up 0.0.0.0:80->80/tcp, 0.0.0.0:8404->8404/tcp
haproxy-demo-app1-1 Up 80/tcp
haproxy-demo-app2-1 Up 80/tcp
haproxy-demo-app3-1 Up 80/tcp
Step 5: Test load balancing
Send several requests and watch the response rotate:
for i in $(seq 1 6); do curl -s http://localhost/; echo; done
Expected output:
<h1>Backend 1</h1>
<h1>Backend 2</h1>
<h1>Backend 3</h1>
<h1>Backend 1</h1>
<h1>Backend 2</h1>
<h1>Backend 3</h1>
Step 6: Check the Stats UI
Open http://localhost:8404/stats in a browser, log in with admin / admin123.
The Stats UI shows:
- Status of each backend (UP / DOWN)
- Request count
- Current connections
- Failed health check count
Testing health checks
Stop one backend manually:
docker compose stop app2
Send more requests — HAProxy removes app2 from the pool and rotates only between app1 and app3. No requests fail.
Bring it back:
docker compose start app2
After 2 successful health checks (~10 seconds), HAProxy automatically returns app2 to the pool.
Common errors
HAProxy starts but requests return 503
Health checks run as soon as HAProxy starts. If backends aren't ready yet, HAProxy marks them DOWN and returns 503. Wait 10–15 seconds for backends to finish starting. Or add healthcheck to the backend services in Compose.
cannot bind socket error on start
Port 80 or 8404 is already in use. Check:
sudo ss -tlnp | grep ':80\|:8404'
Config parse error
HAProxy validates config strictly. Check the logs to find the offending line:
docker compose logs haproxy
Common causes: missing space before check, wrong server name in backend, or timeout without a unit.
When to switch to leastconn
roundrobin works well for lightweight, fast-completing requests. For workloads like WebSockets, large file uploads, or slow-processing APIs, switch to leastconn to avoid one backend accumulating a backlog:
backend web_backends
balance leastconn
...
Session stickiness with source
If the app requires users to always hit the same backend (e.g., local session without shared cache):
backend web_backends
balance source
hash-type consistent
...
hash-type consistent means adding or removing a backend only redistributes a small fraction of requests rather than reshuffling all of them.
When not to use HAProxy this way
- App is on Kubernetes: use Service + Ingress instead
- Complex SSL termination across many domains: Nginx or Caddy are easier to configure
- Only one backend: no need for a load balancer, just wasted overhead
Production checklist
- Change
stats authto a strong password, or disable the Stats UI if not needed - Enable SSL on the
frontendif the app is exposed to the internet - Configure logging to a file or log collector instead of stdout
- Tune
timeoutvalues to match the app's characteristics - Manually kill each backend to test health check behavior before real deployment