Caddy’s load balancing isn’t just about distributing requests; it’s about intelligently routing traffic to healthy backend servers, ensuring your application stays available even when individual instances falter.

Let’s see Caddy in action. Imagine we have two backend Go web servers running on ports 9001 and 9002, and we want Caddy to balance traffic between them on port 8080.

Here’s a minimal Caddyfile:

:8080 {
    reverse_proxy 127.0.0.1:9001 127.0.0.1:9002 {
        health_uri /health
        health_interval 10s
        health_timeout 5s
    }
}

Now, let’s start two simple Go servers.

Server 1 (server1.go):

package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from Server 1!\n")
	})
	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintf(w, "OK\n")
	})
	port := os.Getenv("PORT")
	if port == "" {
		port = "9001"
	}
	fmt.Printf("Server 1 listening on :%s\n", port)
	http.ListenAndServe(":"+port, nil)
}

Server 2 (server2.go):

package main

import (
	"fmt"
	"net/http"
	"os"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from Server 2!\n")
	})
	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		fmt.Fprintf(w, "OK\n")
	})
	port := os.Getenv("PORT")
	if port == "" {
		port = "9002"
	}
	fmt.Printf("Server 2 listening on :%s\n", port)
	http.ListenAndServe(":"+port, nil)
}

Compile and run them: go run server1.go & go run server2.go &

Then, run Caddy with the Caddyfile: caddy run

Now, if you repeatedly hit http://localhost:8080 in your browser or with curl, you’ll see responses alternating between "Hello from Server 1!" and "Hello from Server 2!". Caddy is distributing the load.

The reverse_proxy directive is the core. It takes a list of upstream addresses. Caddy defaults to a round-robin policy for distributing requests among these upstreams. However, the real magic for availability comes with the health check configuration.

health_uri /health: This tells Caddy to periodically send an HTTP GET request to the /health endpoint on each upstream server. health_interval 10s: Caddy will perform this health check every 10 seconds. health_timeout 5s: Caddy will wait a maximum of 5 seconds for a response from the health check endpoint. If it doesn’t get one within this time, the server is considered unhealthy.

If a health check fails (e.g., returns a non-2xx or non-3xx status code, or times out), Caddy will temporarily stop sending traffic to that unhealthy upstream. Once the upstream becomes healthy again (passes a subsequent health check), Caddy will automatically re-include it in the load balancing pool.

This health checking mechanism is crucial for maintaining high availability. Without it, if server1 crashed, Caddy would continue sending requests to it indefinitely, leading to errors for users. With health checks, Caddy quickly detects the failure and directs all traffic to server2 until server1 is back online.

You can customize the health check further. For instance, you can specify the expected response body or check for specific headers. You can also configure health_policy to change how Caddy determines health (e.g., interval, timeout, max_fails, unhealthy_interval).

Consider a scenario where server1 is under heavy load and its /health endpoint is slow to respond. Even if the rest of the application is functional, if the health check times out, Caddy will mark it as unhealthy. This highlights the importance of ensuring your health check endpoint is lightweight and fast, performing only the essential checks to confirm the server’s basic operational status.

The reverse_proxy directive supports various load balancing policies beyond round-robin, such as least_conn (sends requests to the upstream with the fewest active connections) or random. You can specify these using policy <policy_name>.

When Caddy performs a health check, it uses a separate, internal HTTP client. This client respects the configured health_timeout but doesn’t necessarily inherit all other client settings from the main reverse_proxy configuration unless explicitly defined. The health check itself is an HTTP request, so it can also fail due to network issues between Caddy and the upstream, not just application-level problems on the upstream.

The next step is to explore more advanced load balancing policies and how to configure Caddy to automatically provision TLS certificates for your proxied services.

Want structured learning?

Take the full Caddy course →