HAP
×3
HAProxy LB
round-robin • health • scale

How to Load Balance with HAProxy on Docker Compose

Deploy HAProxy as a Docker Compose load balancer with round-robin routing, health checks, and the Stats UI for monitoring backends.

12 min read16/06/2026

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 requests
  • backend web_backends: defines the backend group and distribution method
  • balance roundrobin: rotates evenly across 3 servers
  • option 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 successes
  • listen 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 auth to a strong password, or disable the Stats UI if not needed
  • Enable SSL on the frontend if the app is exposed to the internet
  • Configure logging to a file or log collector instead of stdout
  • Tune timeout values to match the app's characteristics
  • Manually kill each backend to test health check behavior before real deployment