Skip to content
LinkedIn GitHub X

Exploring Load Balancing in Caddy Using Docker

Cover Images

Hello there! In this post, we’ll dive into the world of Caddy, a modern and powerful web server. Built using Go, Caddy offers a range of built-in features, including reverse proxy and load balancing.

By the way, in this project, I’ll use Caddy 2, the latest version, which comes with a completely rewritten architecture and enhanced features. To make our setup and testing as smooth as possible, we’ll leverage Docker. Docker allows us to create a reproducible environment with all the necessary components easily.

The main focus of this post will be on understanding and experimenting with Caddy’s load balancing algorithms. We’ll explore how Caddy intelligently distributes incoming traffic, the different strategies it offers (such as Round Robin, Least Connection, Random, and more), and how you can configure the for various use cases.

By using Docker, we’ll quickly spin up multiple backend services (which are called “workers”) and route requests through a single Caddy instance acting as our load balancer.

The Project Setup

To get started, let’s look at the project structure. Our setup is straightforward, allowing us to focus on the core concepts of load balancing.

Here’s the project structure:

Terminal window
├── caddy
└── Caddyfile
├── docker-compose.yml
└── src
├── Dockerfile
├── go.mod
└── main.go

Our setup is composed of three main parts:

1. Backend Workers (src): These are simple Go applications the will handle the requests. Each worker will simply return a “Hello form [hostname]” message, allowing us to sess which server is handling the request. This is the perfect way to visualize how Caddy distributes the load.

2. Caddy Load Balancer (caddy): Our Caddy instance will act as the reverse proxy, forwarding requests to the worker services. We’ll use the Caddyfile to define our load balancing rules.

3. Docker Compose (docker.compose.yml): This file orchestrates everything, defining and running our multi-container application with a single-command.

Understanding the Components

Let’s break down the files in our project.

src/main.go

This is a simple Go HTTP server. It listens on port 8081 and responds to any request by printing its hostname. This is our primary tool for observing the load balancing behavior.

package main
import (
"fmt"
"net/http"
"os"
)
func handler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
fmt.Fprintf(w, "Hello from %s\n", hostname)
}
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8081", nil)
}

src/Dockerfile

This Dockerfile builds our Go application into a lightweight, self-contained Docker image.

FROM golang:1.24 AS builder
WORKDIR /go/src/app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o /go/bin/app
FROM golang:1.24-alpine
COPY --from=builder /go/bin/app /
EXPOSE 8081
ENV PORT 8081
CMD ["/app"]

docker-compose.yml

This file ties everything together. We define a service for our Caddy load balancer and three worker services.

version: "3.8"
services:
load_balancer:
image: caddy:2.10-alpine
container_name: load_balancer
ports:
- "8082:80"
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
worker_1:
build: ./src
container_name: worker_1
hostname: worker_1
expose:
- "8081"
worker_2:
build: ./src
container_name: worker_2
hostname: worker_2
expose:
- "8081"
worker_3:
build: ./src
container_name: worker_3
hostname: worker_3
expose:
- "8081"

caddy/Caddyfile

This is where the magic happens! The Caddyfile is Caddy’s configuration file. We’ll define a reverse proxy that routes to our workers. The lb_policy directive is where we’ll specify our load balancing algorithms.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy round_robin
health_uri /
health_interval 3s
}
}

Experimenting with Load Balancing Algorithms

Now that our project is set up, we can start experimenting. To run the project, simply execute docker-compose up in your terminal. You can then send requests to http://127.0.0.1:8082 and observe which worker responds.

1. Round Robin (lb_policy round_robin)

This is the most common and simplest load balancing algorithm. Caddy distributes incoming requests to the backend servers in a sequential, rotating manner. It’s a fair and predictable method, assuming all servers are equally capable of handling the load.

How to Configure:

Modify your caddy/Caddyfile to use the round_robin policy.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy round_robin
health_uri /
health_interval 3s
}
}

How to Test:

After running docker-compose up -d --build, open your terminal and send a few requests using curl. You should see that Caddy distributes the traffic evenly among the three workers.

Terminal window
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1
$ curl http://127.0.0.1:8082
# Output: Hello from worker_2
$ curl http://127.0.0.1:8082
# Output: Hello from worker_3
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1

Now, let’s test Caddy’s fault tolerance. In a real-world scenario, a server might crash or become unresponsive. We’ll simulate this by manually stopping one of the workers.

Run the following command in your terminal to stop worker_1:

Terminal window
$ docker stop worker_1

After a few moments (the health_interval you set, e.g., 3 seconds), Caddy will perform its next health check, detect that worker_1 is unresponsive, and automatically mark it as unhealthy.

Now, send a few more requests. What do you expect to happen? With worker_1 down, Caddy should intelligently stop routing traffic to it and redirect all requests to the remaining healthy servers (worker_2 and worker_3).

Demo Round Robin

2. Weighted Round Robin (lb_policy weighted_round_robin)

This algorithm is a more advanced version of Round Robin. It allows you to assign a “weight” to each backend server, which determines its share of the requests. Servers with a higher weight will receive more traffic than those with a lower weight. This is ideal when you have servers with varying capacities, for example, a new, more powerful server and an older, less powerful one.

You can also use this policy to gradually drain traffic from an old server or ramp up traffic to a new one during deployments, making it a very useful strategy.

How to Configure:

To use this policy, you need to add the weight to each server’s address in the Caddyfile. For our example, let’s give worker_1 a higher weight of 3, while worker_2 and worker_3 each have a weight of 1. This means worker_1 should handle three out of every five requests.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy weighted_round_robin 3 1 1
health_uri /
health_interval 3s
}
}

After updating the Caddyfile, make sure to reload or restart your Caddy container to apply the changes. You can do this with docker-compose up -d --build.

How to Test:

Now, let’s send a few requests to our load balancer and see how Caddy distributes the traffic according to the assigned weights. Send a few requests using curl and observe the responses.

Terminal window
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1
$ curl http://127.0.0.1:8082
# Output: Hello from worker_2
$ curl http://127.0.0.1:8082
# Output: Hello from worker_3
$ curl http://127.0.0.1:8082
# Output: Hello from worker_1

Weighted Round Robin Demo

3. Least Connection (lb_policy ip_hash)

Aight, let’s dive into Least Connection. Unlike Round Robin, which is a simple, sequential algorithm, Least Connection is a dynamic and more intelligent load balancing policy. It chooses the backend server with the fewest number of currently active requests. This policy is excellent for situations where your requests have a highly variable processing time.

For example, if one of your servers gets a handful of complex, long-running requests while the others are handling many small, quick ones, this algorithm will automatically route new traffic to the servers that are less burdened, preventing a single server from becoming a bottleneck. If there’s a tie, meaning two or more servers have the same lowest number of connections, Caddy will randomly choose one of them.

How to Configure:

Configuring this policy is simple. You just need to change the lb_policy directive in your Caddyfile.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy least_conn
health_uri /
health_interval 3s
}
}

After updating your Caddyfile, make sure to restart your Caddy container with docker-compose up -d --build to apply the changes.

How to Test:

To demonstrate the Least Connection algorithm, you’ll need to modify your Go code to simulate a long-running request. This will allow you to see how Caddy intelligently routes traffic away from the busy worker.

- Update Your Go Code

Open your src/main.go file and add a new handler that will simulate a task with a significant delay. This will act as our “long-running request.”

package main
import (
"fmt"
"net/http"
"os"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
fmt.Fprintf(w, "Hello from %s\n", hostname)
}
func longHandler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
time.Sleep(10 * time.Second)
fmt.Fprintf(w, "Hello! long running request finished from %s\n", hostname)
}
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/long", longHandler)
http.ListenAndServe(":8081", nil)
}

- Rebuild and Run Docker Compose

After updating your code, you must rebuild and run your containers to apply the changes.

Terminal window
$ docker-compose up -d --build

- Test the Scenario

Now, you can test the Least Connection algorithm using two separate terminals.

Terminal 1 (Long-Running Request):

Start a request to the /long endpoint. This will open a connection to one of the workers and hold it for 10 seconds. Caddy will detect that this worker has an active, ongoing connection.

Terminal window
$ curl http://127.0.0.1:8082/long

Terminal 2 (Normal Requests):

Immediately after running the command in Terminal 1, switch to Terminal 2 and send several quick requests to the root endpoint (/).

Terminal window
$ curl http://127.0.0.1:8082/
# Output: Hello from worker_2
$ curl http://127.0.0.1:8082/
# Output: Hello from worker_3
$ curl http://127.0.0.1:8082/
# Output: Hello from worker_2

You will observe that Caddy will not send requests to the worker that is currently busy with the long-running request. Instead, all new requests will be routed to the other two workers, demonstrating how the least_conn algorithm effectively balances load dynamically. Once the long-running request is complete, that worker will once again be available to handle new requests.

Least Connection

4. IP Hash (lb_policy ip_hash)

The IP Hash load balancing algorithm is different from the previous ones because it’s focused on session persistence. Instead of distributing requests based on a sequential or random order, it creates a hash from the client’s IP address and uses that hash to consistently route all requests from that same client to the same backend server.

How to Configure:

Configuring the IP Hash policy is straightforward. You simply need to replace the lb_policy directive in your Caddyfile with ip_hash.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy ip_hash
health_uri /
health_interval 3s
}
}

After updating your Caddyfile, make sure to restart your Caddy container with docker-compose up -d --build to apply the changes.

How to Test:

To test this algorithm, you’ll need to send requests from different “clients” (i.e., different IP addresses) and observe where they are routed. The easiest way to simulate this is by sending requests from your local machine and then using a proxy or a different network to see if the requests are routed to a different server.

IP Hash Demo

No matter how many times you run curl from the same machine, the requests will always be routed to the same worker. This is because Caddy is hashing your local IP address (127.0.0.1 or the container’s internal IP) and consistently mapping it to that specific worker.

This demonstrates how IP Hash ensures session stickiness without needing to share session data across all servers. It’s a powerful tool for maintaining a consistent user experience.

5. Random (lb_policy random)

The Random load balancing policy is the simplest and most unpredictable of all the algorithms. As its name suggests, it selects a backend server at random for each new request. There is no sequential pattern or special logic; every request has an equal chance of being routed to any of the available servers.

While it may seem less sophisticated than other algorithms, the Random policy is surprisingly effective in many scenarios. It’s fast, has a very low overhead, and can be a great choice for distributing traffic evenly across a large pool of homogenous servers. It naturally avoids the “thundering herd” problem that can sometimes occur with Round Robin on first-come-first-served requests, as it prevents all clients from hitting the same server at the same time.

How to Configure:

Configuring this policy is the easiest. Simply replace the lb_policy directive in your Caddyfile with random.

:80 {
reverse_proxy worker_1:8081 worker_2:8081 worker_3:8081 {
lb_policy random
health_uri /
health_interval 3s
}
}

After updating your Caddyfile, make sure to restart your Caddy container with docker-compose up -d --build to apply the changes.

How to Test:

To test the Random policy, send a series of quick requests and observe the output. Unlike the predictable pattern of Round Robin or the consistent output of IP Hash, the responses will come from different workers in an unpredictable order.

Terminal window
curl http://127.0.0.1:8082
# Output: Hello from worker_3
curl http://127.0.0.1:8082
# Output: Hello from worker_1
curl http://127.0.0.1:8082
# Output: Hello from worker_2
curl http://127.0.0.1:8082
# Output: Hello from worker_1

Demo Random

Conclusion

Throughout this post, we’ve explored the core capabilities of Caddy as a powerful web server and a flexible load balancer. Using a simple Docker setup, we were able to quickly demonstrate five different load balancing algorithms, each with its own unique advantages:

- Round Robin: The classic, simple approach for evenly distributing traffic in a predictable sequence.

- Weighted Round Robin: A smarter version of Round Robin that allows you to prioritize traffic to more powerful servers.

- Least Connection: A dynamic algorithm that routes traffic based on real-time load, preventing a single server from becoming a bottleneck.

- IP Hash: The ideal choice for session stickiness, ensuring a consistent user experience by always routing a client to the same backend server.

- Random: A straightforward and fast algorithm for scattering traffic across servers, effective for a large pool of homogenous workers.

This hands-on experience proved that Caddy is not just a simple web server but a robust tool for building scalable and reliable applications. Caddy’s elegant syntax and powerful features make it an excellent choice for anyone looking to simplify their server configurations without sacrificing control or performance. Whether you’re a seasoned developer or just starting, Caddy offers a smooth and intuitive experience that can handle everything from a single website to a complex, distributed application.

References:

- Caddy Documentation: Reverse Proxy

- Caddy Community Wiki