Fault-Tolerant Load Balancing with Docker

Build a load-balanced system with HAProxy and watch it self-heal in real time.

Introduction

30 Second Summary

When you stream a show or buy something online, your request hits one of many servers working behind the scenes. A load balancer sits in front, picking which server handles each request and quietly routing around any that go down.

In this project, you will build this system on your own laptop using Docker and HAProxy. You will launch three backend servers behind a load balancer, then break things on purpose and watch the system heal itself in real time.

What You'll Build

You will demo a working load balancer by killing a backend server and watching traffic reroute to healthy servers in real time, with zero failed requests.

By the end of this project, you'll have:

  • A running system where you send requests and watch them distribute evenly across 3 backends, each responding with its own unique identity.
  • A real-time monitoring dashboard showing backend health status, request counts, and connection data for every server.
  • Proven fault tolerance by killing a backend container, watching traffic reroute with zero downtime, and bringing it back to see automatic recovery.
  • An optional Secret Mission to push your skills further.

Are there any prerequisites?

All you need is Docker Desktop installed and running on your machine. This project uses Docker Compose to manage all the containers, so everything starts with a single command.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project Structure

Your fault-tolerant system will have four moving parts: three backend web servers and one load balancer. Each one needs its own configuration files, and Docker Compose reads a specific directory structure to know how to build them all.

In this step, you'll create the project skeleton that Docker Compose needs. Once the directories and files are in place, you'll be ready to fill in the actual configurations.

In this step, get ready to:
  • Create a project directory with subdirectories for each service.
  • Add HTML pages that identify each backend server.
  • Create placeholder configuration files for Docker Compose and HAProxy.
Create the project directory

Each backend gets its own directory with an HTML file that Nginx will serve. HAProxy also gets its own directory for its configuration file.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and its subdirectories by running these commands:
mkdir -p fault-tolerant-lb/app1 fault-tolerant-lb/app2 fault-tolerant-lb/app3 fault-tolerant-lb/haproxy
cd fault-tolerant-lb

What do these commands do?

  • mkdir -p creates multiple directories at once. The -p flag creates any parent directories that don't exist yet.
  • This creates the fault-tolerant-lb project folder with four subdirectories: app1, app2, app3 for the three backend servers, and haproxy for the load balancer configuration.
  • cd fault-tolerant-lb moves you into the new project folder so all future commands run from inside it.
  • Confirm the directory structure was created by running:
ls

You should see four directories: app1, app2, app3, and haproxy.

Don't see the four directories?

  • Make sure you ran the mkdir -p command from the ~/Desktop directory.
  • Check for typos in the directory names. They should be exactly app1, app2, app3, and haproxy.
  • Why don't I see the four directories after running mkdir?
Add the backend HTML files

Each backend needs an HTML page that identifies which server responded to a request. When you test your load balancer later, these different responses prove that traffic is being distributed across all three backends.

  • Create the HTML file for the first backend by running this command:
cat > app1/index.html << 'EOF'
<!DOCTYPE html>
<html>
<head><title>Backend 1</title></head>
<body>
<h1>Response from Backend 1</h1>
</body>
</html>
EOF

What does this file do?

  • cat > app1/index.html << 'EOF' creates a new file and writes everything between the two EOF markers into it.
  • The HTML page has a heading that says "Response from Backend 1". When Nginx serves this file, the response tells you exactly which backend handled your request.
  • Create the HTML file for the second backend by running:
cat > app2/index.html << 'EOF'
<!DOCTYPE html>
<html>
<head><title>Backend 2</title></head>
<body>
<h1>Response from Backend 2</h1>
</body>
</html>
EOF
  • Confirm both files were created by running:
ls app1 app2

You should see index.html listed under both directories.

  • Create the HTML file for the third backend by running:
cat > app3/index.html << 'EOF'
<!DOCTYPE html>
<html>
<head><title>Backend 3</title></head>
<body>
<h1>Response from Backend 3</h1>
</body>
</html>
EOF
  • Verify all three HTML files exist by running:
ls app1 app2 app3

You should see index.html listed under each directory.

Missing an index.html file?

  • Make sure you ran each cat command from inside the fault-tolerant-lb directory, not from inside a subdirectory.
  • Check that you included the closing EOF on its own line with no spaces before it.
  • Why is my index.html missing from one of the backend directories?
Create the placeholder configuration files

Your project needs two more files. docker-compose.yml tells Docker how to build and connect your services. haproxy.cfg tells the load balancer where to route traffic.

  • Create both placeholder files by running these commands:
touch docker-compose.yml
touch haproxy/haproxy.cfg

What does touch do?

touch creates an empty file if one doesn't already exist. These files are placeholders that you'll fill in with real configuration in the next steps.

Before you run the next command, think about what you expect to see. What files and directories should be in your project folder right now?

  • Check your complete project structure by running:
ls

You should see app1, app2, app3, docker-compose.yml, and haproxy.

  • Also verify the HAProxy config file was created by running:
ls haproxy

You should see haproxy.cfg listed.

Missing docker-compose.yml or haproxy.cfg?

  • Make sure you ran the touch commands from inside the fault-tolerant-lb directory.
  • The haproxy/ directory must already exist from the mkdir step. If it's missing, re-run the mkdir -p command from the first substep.
  • Why are my configuration files missing from the project directory?

Your project skeleton is in place. Next up, you'll fill in the HAProxy configuration and Docker Compose file to bring your backends and load balancer to life.

Launch the Backends and a Basic Load Balancer

Your project structure is in place with all the files ready to fill in. Now it's time to bring the system to life by writing real configuration and getting traffic flowing.

In this step, you'll configure HAProxy to distribute incoming requests across your three backends using round-robin. Each backend will respond with its own identity, proving the load balancer is routing traffic evenly.

In this step, get ready to:
  • Write the HAProxy configuration with round-robin load balancing.
  • Define the backend and load balancer services in Docker Compose.
  • Launch the system and confirm traffic distributes across all three backends.
Configure HAProxy for round-robin load balancing

HAProxy needs a configuration file that tells it two things: where to listen for incoming traffic, and where to forward that traffic. You'll define both in a single config file.

  • Open haproxy/haproxy.cfg in your text editor and paste the following configuration:
global
    # Maximum number of concurrent connections
    maxconn 256

defaults
    # Use HTTP mode for all proxies
    mode http
    # Timeouts for connecting, client idle, and server idle
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

frontend http_front
    # Accept incoming traffic on port 80
    bind *:80
    # Route all requests to the backend group
    default_backend web_backends

backend web_backends
    # Distribute requests evenly across servers in order
    balance roundrobin
    # Each server points to an Nginx container on port 80
    server backend1 app1:80
    server backend2 app2:80
    server backend3 app3:80

What does this configuration do?

  • global sets process-wide limits. maxconn 256 caps HAProxy at 256 simultaneous connections.
  • defaults applies shared settings to all proxies. mode http tells HAProxy to operate at the HTTP layer, and the three timeouts prevent connections from hanging forever.
  • frontend http_front is the entry point. It listens on port 80 and sends every request to the web_backends group.
  • backend web_backends defines the pool of servers. balance roundrobin means HAProxy sends each new request to the next server in the list, cycling through all three in order.
  • Save haproxy.cfg.

Not sure which editor to use?

Any text editor works. You can use TextEdit (set it to plain text mode first), VS Code, or even nano in the terminal. Just make sure you save the file as plain text, not rich text.

Help me choose a text editor for editing config files.

✔️ Awesome, I've got everything!

Great. Make sure you've saved haproxy.cfg before moving on.

ⓧ I'd like to double check the full code

Here is the complete haproxy/haproxy.cfg file. Make sure yours matches this exactly.

global
    maxconn 256

defaults
    mode http
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

frontend http_front
    bind *:80
    default_backend web_backends

backend web_backends
    balance roundrobin
    server backend1 app1:80
    server backend2 app2:80
    server backend3 app3:80
Define the services in Docker Compose

Docker Compose needs to know which containers to run and how they connect to each other. You'll define four services: three Nginx web servers for the backends and one HAProxy container for the load balancer.

  • Open docker-compose.yml and paste the following configuration:
services:
  # Backend 1 - serves app1/index.html
  app1:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app1:/usr/share/nginx/html:ro

  # Backend 2 - serves app2/index.html
  app2:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app2:/usr/share/nginx/html:ro

  # Backend 3 - serves app3/index.html
  app3:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app3:/usr/share/nginx/html:ro

  # Load balancer - routes traffic to all 3 backends
  haproxy:
    image: haproxy:3.4-alpine
    ports:
      - "80:80"
    volumes:
      - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
    depends_on:
      - app1
      - app2
      - app3

What does this configuration do?

  • Each app service uses the nginx:1.30.4-alpine image, a lightweight web server. The volumes line mounts your local HTML folder into the container so Nginx serves your custom page.
  • The :ro at the end of each volume mount means read-only. The containers can serve the HTML files but cannot modify them.
  • The haproxy service exposes port 80 to your machine, so http://localhost hits HAProxy. It mounts your haproxy.cfg into the container's expected config path.
  • depends_on ensures Docker starts the three backend containers before HAProxy, so HAProxy has something to route to.
  • Save docker-compose.yml.

Seeing YAML formatting errors?

YAML is sensitive to indentation. Make sure you use spaces, not tabs. Each level of nesting is two spaces deeper than the one above it.

Help me fix my docker-compose.yml formatting.

✔️ Awesome, I've got everything!

Great. Make sure you've saved docker-compose.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml file. Make sure yours matches this exactly.

services:
  app1:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app1:/usr/share/nginx/html:ro

  app2:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app2:/usr/share/nginx/html:ro

  app3:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app3:/usr/share/nginx/html:ro

  haproxy:
    image: haproxy:3.4-alpine
    ports:
      - "80:80"
    volumes:
      - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
    depends_on:
      - app1
      - app2
      - app3
Launch the system and test round-robin distribution

Both configuration files are ready. Time to spin up all four containers and see load balancing in action.

  • In your terminal, make sure you are inside the load-balancer project directory (the folder containing docker-compose.yml).
  • Start all four containers in the background by running this command:
docker compose up -d

What does this command do?

docker compose up reads your docker-compose.yml, pulls the required images (Nginx and HAProxy), creates the containers, and starts them. The -d flag runs everything in detached mode, so the containers run in the background and you keep your terminal.

The first run takes a minute or two because Docker needs to download the Nginx and HAProxy images. Future runs will be much faster.

You should see output showing each container being created and started. All four containers should show as "Started".

Seeing a port conflict error?

If you see an error about port 80 already being in use, another application on your machine is using that port. Check if you have another web server running (like Apache or a different Nginx instance) and stop it first.

If Docker itself is not responding, make sure Docker Desktop is running. You should see the Docker whale icon in your macOS menu bar.

Help me fix my docker compose up error.

Now for the moment of truth. When you send a request to http://localhost, HAProxy receives it and forwards it to one of the three backends. Before you test this, think about what you expect to see. Will every request go to the same backend, or will it change?

  • Send your first request to the load balancer by running:
curl http://localhost

You should see the full HTML page from one of your backends, including Response from Backend 1 (or 2 or 3).

  • Run the same curl command a few more times to watch round-robin in action:
curl http://localhost
curl http://localhost
curl http://localhost

Each response cycles through a different backend. You should see Response from Backend 1, then Response from Backend 2, then Response from Backend 3, and then back to Backend 1 again. That's round-robin load balancing at work.

Why does each backend respond differently?

Each Nginx container serves its own index.html file with a unique heading. When HAProxy forwards a request to app2, you get "Response from Backend 2". This identity tag lets you see exactly which server handled each request.

All responses coming from the same backend?

Make sure your haproxy.cfg has balance roundrobin in the backend web_backends section. Also confirm that all three server lines are present and point to app1:80, app2:80, and app3:80.

If you changed the config after starting the containers, restart the stack by running docker compose down followed by docker compose up -d.

Help me troubleshoot why my HAProxy round-robin isn't working.

Your load balancer is distributing traffic across all three backends. But there's a catch. Right now, HAProxy has no idea if a backend is actually healthy. In the next step, you'll find out what happens when one of those backends goes down.

Break the System and Watch It Fail

Your load balancer is working perfectly. Every request cycles through all three backends in order, and you get a response every time.

But what happens when a server goes down? In this step, you'll crash a backend on purpose and discover a critical blind spot in your current setup.

In this step, get ready to:
  • Stop a backend container to simulate a server crash.
  • Send requests and observe how HAProxy handles routing to a dead backend.
Stop a backend container

Docker Compose lets you stop individual services while the rest of the system keeps running. This is a clean way to simulate a backend server crashing in production.

  • Stop the app2 container by running this command:
docker compose stop app2

You'll see a short confirmation message that app2 has stopped. The other three containers (app1, app3, and haproxy) are still running.

What does this command do?

docker compose stop shuts down a specific container without removing it. The container still exists on disk, so you can restart it later with docker compose start.

Seeing an error when stopping app2?

  • Make sure you are in the same folder where you ran docker compose up -d in the previous step. Docker Compose needs the docker-compose.yml file to know which containers to manage.
  • Check that Docker Desktop is still running in your menu bar.
  • Help me troubleshoot the docker compose stop command.
Send requests and watch them fail

HAProxy still has three backends in its round-robin rotation. It has no idea that app2 is gone.

Before you run the next command, make a prediction. You have three backends listed in HAProxy's configuration, but only two are actually running. What do you think will happen when you send six requests?

  • Send six requests in a row by running this command:
for i in 1 2 3 4 5 6; do
  echo '=== Request' $i '==='
  curl -s http://localhost
  echo ''
done

Look at the output. Some requests returned the familiar "Response from Backend" HTML. Others returned an error page with a 502 status. Roughly two out of every six requests failed.

Why are some requests failing?

  • HAProxy uses round-robin, cycling through backend1, backend2, backend3 in order.
  • It has no mechanism to check whether a backend is alive or dead. It just follows the rotation blindly.
  • Every time it is backend2's turn, the request fails with a 502 error because the container is not running.
  • Your users would see these errors randomly. One out of every three requests fails with no warning.

Every failed request is a real user seeing an error page. In a production system, a third of your traffic would be broken.

This is the problem health checks solve

In production, servers crash, networks glitch, and deployments go wrong. A load balancer without health checks keeps sending traffic into a black hole.

Health checks let the load balancer probe each backend periodically. When a backend stops responding, the load balancer removes it from rotation automatically. That is exactly what you will add in the next step.

All six requests succeeded?

  • Confirm that app2 is actually stopped. Run docker compose stop app2 again and then rerun the loop.
  • If you see "Connection refused" instead of a 502 error, the HAProxy container itself may have stopped. Run docker compose up -d to restart everything, then stop only app2 again.
  • Help me figure out why I'm not seeing 502 errors after stopping app2.

You have seen the problem firsthand. HAProxy blindly routes to dead backends, and your users pay the price. Next up, you will add health checks and a real-time stats dashboard so HAProxy can detect failures and reroute traffic automatically.

Add Health Checks and the Stats Dashboard

You just watched HAProxy route requests to a dead backend and return 502 errors. The root cause is simple: HAProxy had no way to know app2 was down. It kept sending traffic there because nothing told it to stop.

In this step, you'll add HTTP health checks that probe each backend every 3 seconds. You'll also set up a real-time stats dashboard so you can watch backend health status change live.

In this step, get ready to:
  • Add HTTP health checks to detect and remove unhealthy backends.
  • Set up a real-time stats dashboard for live monitoring.
  • Verify all backends show as healthy in the dashboard.
Add HTTP health checks to the backend section

Health checks are periodic test requests that HAProxy sends to each backend. If a backend stops responding, HAProxy marks it as down and stops routing traffic to it.

  • In haproxy/haproxy.cfg, find the backend web_backends section.
  • Replace the entire backend web_backends section (from backend web_backends through the last server line) with the following:
backend web_backends
    balance roundrobin
    option httpchk GET /
    http-check expect status 200
    server backend1 app1:80 check inter 3s fall 2 rise 2
    server backend2 app2:80 check inter 3s fall 2 rise 2
    server backend3 app3:80 check inter 3s fall 2 rise 2

What do these new lines do?

  • option httpchk GET / tells HAProxy to send an HTTP GET request to / on each backend. Since Nginx serves index.html at that path, this verifies the full HTTP stack is working.
  • http-check expect status 200 means HAProxy considers a backend healthy only if it responds with a 200 status code.
  • check inter 3s fall 2 rise 2 on each server line means: check every 3 seconds, mark as DOWN after 2 consecutive failures, mark as UP after 2 consecutive successes.

Don't save yet. You have one more section to add to this file.

Add the stats dashboard section

HAProxy includes a built-in stats page that shows real-time data about your backends. You'll use it to watch health status, request counts, and connection data in a live dashboard.

  • Still in haproxy/haproxy.cfg, add the following section below the backend web_backends block. Leave one blank line between them:
listen stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 5s
    stats auth admin:admin

What does this section do?

  • listen stats creates a combined frontend+backend section dedicated to the stats page.
  • bind *:8404 puts the dashboard on port 8404, keeping it separate from your main traffic on port 80.
  • stats refresh 5s auto-refreshes the page every 5 seconds so you can watch changes live.
  • stats auth admin:admin protects the page with basic authentication (username: admin, password: admin).
  • Save haproxy/haproxy.cfg.

✔️ Awesome, I've got everything!

Great. Make sure you've saved haproxy/haproxy.cfg before moving on.

ⓧ I'd like to double check the full code

Here is the complete haproxy/haproxy.cfg file. Make sure yours matches exactly:

global
    maxconn 256

defaults
    mode http
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

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 3s fall 2 rise 2
    server backend2 app2:80 check inter 3s fall 2 rise 2
    server backend3 app3:80 check inter 3s fall 2 rise 2

listen stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 5s
    stats auth admin:admin
  • Restart the stack to apply the new HAProxy config by running:
docker compose up -d

What happens when you run this?

Docker Compose recreates the HAProxy container with your updated config. It also restarts app2, which you stopped in the previous step. All 4 containers are now running.

  • Confirm all 3 backends are responding again by running:
curl http://localhost
  • Run the curl command a few more times. You should see responses from Backend 1, Backend 2, and Backend 3 cycling through again.

Not seeing all 3 backends?

  • Make sure you saved haproxy/haproxy.cfg before running docker compose up -d. Unsaved changes won't be picked up.
  • Check that the indentation in your config uses spaces, not tabs. HAProxy is sensitive to formatting.
  • help me debug why my backends aren't responding after updating haproxy.cfg
Expose the stats port and open the dashboard

The stats dashboard is running inside HAProxy on port 8404, but Docker isn't exposing that port to your machine yet. You need to add it to docker-compose.yml.

  • In docker-compose.yml, find the ports section under the haproxy service.
  • Find this line:
    ports:
      - "80:80"
  • Add - "8404:8404" on the line below - "80:80", so the section looks like this:
    ports:
      - "80:80"
      - "8404:8404"
  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Make sure you've saved docker-compose.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml file. Make sure yours matches exactly:

services:
  app1:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app1:/usr/share/nginx/html:ro

  app2:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app2:/usr/share/nginx/html:ro

  app3:
    image: nginx:1.30.4-alpine
    volumes:
      - ./app3:/usr/share/nginx/html:ro

  haproxy:
    image: haproxy:3.4-alpine
    ports:
      - "80:80"
      - "8404:8404"
    volumes:
      - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
    depends_on:
      - app1
      - app2
      - app3
  • Restart the stack to pick up the new port mapping by running:
docker compose up -d

Before you open the dashboard, what do you think each backend's health status will show?

  • Open http://localhost:8404/stats in your browser.
  • When prompted, enter admin for both the username and password.

You should see all 3 backends (backend1, backend2, backend3) listed with green UP status indicators. The dashboard auto-refreshes every 5 seconds.

What am I looking at?

The stats dashboard shows a row for each backend server. The Status column tells you whether HAProxy considers each backend healthy (UP) or unhealthy (DOWN). You'll also see columns for session counts, bytes transferred, and response times.

This is the same information production teams use to monitor real infrastructure. You'll use it in the next step to watch fault tolerance happen in real time.

Can't reach the stats dashboard?

  • Make sure you added - "8404:8404" under ports in docker-compose.yml and saved the file before restarting.
  • Check that the listen stats section in haproxy.cfg uses bind *:8404 with no typos. The port numbers must match between the config and the Compose file.
  • Make sure the URL is http://localhost:8404/stats (not /stat or a different path).
  • help me debug why I can't access the HAProxy stats dashboard

Your system now has eyes. HAProxy can detect when a backend goes down and you can watch it happen through the stats dashboard. Next up, you'll put this to the test by breaking the system again and proving that health checks prevent the failures you saw earlier.

Prove Fault Tolerance Works

In Step 4, you added health checks and a real-time stats dashboard to your HAProxy load balancer. The system now probes each backend every 3 seconds and knows exactly which servers are alive.

Time for the real test. You are going to repeat the exact same failure from Step 3, where killing a backend caused 1 in 3 requests to fail with errors. With health checks active, the outcome should be very different.

In this step, get ready to:
  • Stop a backend and confirm HAProxy reroutes traffic with zero failed requests.
  • Bring the backend back and watch HAProxy automatically recover.
Stop a backend and watch the dashboard

In Step 3, you stopped app2 and HAProxy kept routing traffic to it, causing errors. Let's do the exact same thing and see how health checks change the outcome.

  • Stop the app2 backend by running this command:
docker compose stop app2
  • Switch to the stats dashboard at http://localhost:8404/stats in your browser.

Within a few seconds, app2 turns red and shows a DOWN status. HAProxy detected the failed backend and removed it from the rotation.

What just happened?

HAProxy's health checks probe each backend every 3 seconds. After 2 consecutive failed checks (about 6 seconds), HAProxy marks the backend as DOWN and stops sending it traffic.

Compare this to Step 3, where HAProxy had no health checks and blindly kept routing to the dead backend. Now it knows the backend is gone before any user request hits it.

Still seeing app2 as UP?

Wait about 6 seconds for 2 consecutive failed health checks. The dashboard refreshes every 5 seconds, so it may take a moment to update.

If it still shows UP, make sure you ran the stop command from inside the load-balancer project directory where your docker-compose.yml lives.

Help me figure out why my stats dashboard isn't updating after stopping a container.

Confirm zero failed requests

In Step 3, curling with a dead backend caused roughly 1 in 3 requests to fail with errors. Before you send requests this time, do you think they will succeed or fail?

  • Test the load balancer by running this command 5-6 times:
curl http://localhost

Every single request succeeds. You only see responses from Backend 1 and Backend 3. HAProxy detected that app2 is down and stopped routing traffic to it.

Zero errors, same failure scenario

This is the difference health checks make. Without them (Step 3), HAProxy blindly sent traffic to the dead backend, and users saw 502 errors. With them (now), HAProxy detected the failure within 6 seconds and removed the backend from rotation before any user request could reach it.

Still seeing failed requests?

If you ran curl immediately after stopping app2, HAProxy may not have detected the failure yet. Wait about 6 seconds for 2 failed health checks, then try again.

Confirm your haproxy.cfg includes check inter 3s fall 2 rise 2 on each server line. If those parameters are missing, HAProxy is not running health checks.

Help me troubleshoot why I'm still getting errors after stopping a backend with health checks enabled.

Bring the backend back and watch recovery

You have proven that HAProxy handles failure gracefully. The other half of fault tolerance is automatic recovery. When a backend comes back online, HAProxy should detect it and start routing traffic to it again without any manual intervention.

  • Bring app2 back online by running this command:
docker compose start app2
  • Switch to the stats dashboard in your browser and watch the app2 row.

After about 6 seconds (2 consecutive successful health checks at 3-second intervals), app2 turns green and shows UP again. HAProxy has automatically added it back into the rotation.

Before you send more requests, do you think all 3 backends will appear in the responses right away?

  • Confirm all 3 backends are back in rotation by running this command 5-6 times:
curl http://localhost

Backend 1, Backend 2, and Backend 3 all appear in the responses again. The system has fully self-healed with zero manual configuration.

The full failure-recovery cycle

You just proved the complete cycle that production systems rely on. HAProxy detected a failed backend, removed it from rotation, continued serving traffic with zero errors, detected the backend's recovery, and added it back. All automatically.

This pattern scales to real infrastructure. The same health check and failover logic runs behind websites, APIs, and services handling millions of requests per day.

Not seeing Backend 2 come back?

Make sure you ran docker compose start app2 (not docker compose up app2). The start command restarts a stopped container. Wait about 6-10 seconds after starting for HAProxy to complete 2 successful health checks before curling again.

Help me troubleshoot why Backend 2 isn't coming back after I restarted the container.

Secret mission

Compare Load Balancing Algorithms

Round-robin distributes traffic evenly by rotating through backends in order. But what happens when some requests take longer than others? Swap in a different load balancing algorithm and compare how traffic distribution changes under a new strategy.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine using Docker containers. There are no ongoing cloud costs, but running containers use system memory and occupy ports 80 and 8404.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • 4 Docker containers: app1, app2, app3 (Nginx backends), and haproxy (load balancer).
  • 2 Docker images pulled: nginx:1.30.4-alpine and haproxy:3.4-alpine.
  • Project files on your Desktop: docker-compose.yml, haproxy/haproxy.cfg, and the app1/, app2/, app3/ directories with HTML files.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your load balancer.

  • Your 4 containers keep running in the background via Docker Desktop.
  • Ports 80 and 8404 remain in use on your machine.
  • The stats dashboard stays accessible at http://localhost:8404/stats.

Pause - I'll come back to this later

Stop the running containers to free up memory and ports, but keep all your files and container state intact so you can restart quickly.

  • Stop all containers by running this in your project folder:
docker compose stop
  • Ports 80 and 8404 are freed. Your containers and project files stay on disk.
  • When you are ready to come back, navigate to your project folder and run:
docker compose start

Delete - I don't want to use this again

Remove all containers, networks, and project files. This fully cleans up everything this project created on your machine.

  • Stop and remove all containers and networks by running this from your project folder:
docker compose down
  • Remove the Docker images that were pulled for this project:
docker rmi nginx:1.30.4-alpine haproxy:3.4-alpine
  • Delete your project folder from the Desktop. On macOS, right-click the folder on your Desktop and select Move to Trash. On Windows, right-click and select Delete.

Nice Work!

Nice Work!

Nice work! You built a fault-tolerant load-balanced system from scratch using Docker Compose, HAProxy, and Nginx. You broke things on purpose, watched them fail, fixed them, and proved the fix works.

You've learned how to:

  • Build a multi-service system with 3 Nginx backends behind an HAProxy load balancer distributing traffic via round-robin, all orchestrated by Docker Compose.
  • Experience a real system failure when HAProxy routed traffic to a dead backend, then fix it by adding HTTP health checks and a real-time stats dashboard for live observability of backend health and traffic distribution.
  • Prove fault tolerance by killing a backend and confirming zero failed requests, then watching automatic recovery when the backend came back online.
  • Completed an optional Secret Mission to push your skills further.

Ready to quiz yourself?