xcaddy lets you compile Caddy with your own plugins, but it’s just a wrapper around Go’s build tools.

Let’s say you want to add a simple plugin that logs requests to a custom endpoint. You’d start by creating a new Go module for your plugin.

mkdir caddy-custom-logger
cd caddy-custom-logger
go mod init example.com/custom-logger

Inside this directory, create a main.go file. This file will contain your plugin’s code.

package main

import (
	"example.com/custom-logger/logger" // Import your plugin package
	"github.com/caddyserver/caddy/v2"
	"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
)

func main() {
	// Register the plugin with Caddy's configuration system.
	caddy.RegisterModule(logger.MyCustomLogger{})

	// Register a Caddyfile loader for your plugin.
	httpcaddyfile.RegisterGlobalOption("custom_logger", logger.CustomLoggerCaddyfile)
}

Now, create the logger package directory and an logger.go file within it. This is where the actual plugin logic resides.

mkdir logger
cd logger
touch logger.go

Here’s the logger.go content:

package logger

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/caddyserver/caddy/v2"
	"github.com/caddyserver/caddy/v2/caddyconfig"
	"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
)

// MyCustomLogger is a Caddy handler that logs requests to a custom endpoint.
type MyCustomLogger struct {
	LogEndpoint string `json:"log_endpoint"` // The URL to send logs to
	client      *http.Client
}

// CaddyModule returns the Caddy module information.
func (MyCustomLogger) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "http.handlers.custom_logger", // Unique identifier for the module
		New: func() caddy.Module { return new(MyCustomLogger) },
	}
}

// Provision sets up the handler.
func (ml *MyCustomLogger) Provision(ctx caddy.Context) error {
	if ml.LogEndpoint == "" {
		return fmt.Errorf("log_endpoint cannot be empty")
	}
	ml.client = &http.Client{
		Timeout: 5 * time.Second,
	}
	return nil
}

// ServeHTTP is the main handler logic.
func (ml MyCustomLogger) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy.CallNextHTTPHandler) error {
	// Log the request details.
	logMessage := fmt.Sprintf("Request received: Method=%s, Path=%s, RemoteAddr=%s", r.Method, r.URL.Path, r.RemoteAddr)

	// Send the log to the custom endpoint.
	req, err := http.NewRequest("POST", ml.LogEndpoint, nil)
	if err != nil {
		log.Printf("Error creating log request: %v", err)
	} else {
		req.Body = http.NoBody // No body for this simple log
		resp, err := ml.client.Do(req)
		if err != nil {
			log.Printf("Error sending log to %s: %v", ml.LogEndpoint, err)
		} else {
			defer resp.Body.Close()
			if resp.StatusCode >= 400 {
				log.Printf("Error logging to %s: received status code %d", ml.LogEndpoint, resp.StatusCode)
			} else {
				log.Printf("Log sent successfully to %s", ml.LogEndpoint)
			}
		}
	}

	// Call the next handler in the chain.
	return next(w, r)
}

// CustomLoggerCaddyfile is a Caddyfile directive for the custom logger.
func CustomLoggerCaddyfile(d *caddyconfig.Dispenser) (interface{}, error) {
	ml := new(MyCustomLogger)
	// Expecting the log endpoint as an argument.
	if !d.NextArg() {
		return nil, d.ArgErr()
	}
	ml.LogEndpoint = d.Val()
	return httpcaddyfile.RequestListener(ml.ServeHTTP), nil
}

// Ensure the type implements the necessary interfaces.
var _ caddy.Module = (*MyCustomLogger)(nil)
var _ caddy.Provisioner = (*MyCustomLogger)(nil)
var _ http.Handler = (*MyCustomLogger)(nil)

Now you can use xcaddy to build a custom Caddy binary. Navigate back to the root of your caddy-custom-logger directory.

xcaddy build --output caddy-custom --with example.com/custom-logger

This command tells xcaddy to build a Caddy binary named caddy-custom and to include your example.com/custom-logger plugin.

After the build completes, you’ll have a caddy-custom executable. You can then run it and configure your Caddyfile to use your new directive.

:8080 {
	custom_logger http://localhost:9090/log
	respond "Hello, world!"
}

And you’ll need a simple server listening on http://localhost:9090/log to receive the logs.

package main

import (
	"io"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/log", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			log.Printf("Error reading log body: %v", err)
			http.Error(w, "Error reading body", http.StatusInternalServerError)
			return
		}
		defer r.Body.Close()
		log.Printf("Received log: %s", string(body))
		w.WriteHeader(http.StatusOK)
	})

	log.Println("Log receiver listening on :9090")
	log.Fatal(http.ListenAndServe(":9090", nil))
}

Running this log receiver and then starting your custom Caddy with the Caddyfile will demonstrate your plugin in action. Requests to http://localhost:8080 will trigger your custom logger to send a POST request to http://localhost:9090/log.

The xcaddy tool itself is just a convenience wrapper for go build. It handles fetching the Caddy core and merging your plugin code into a single binary. The real magic is in how Caddy’s module system allows you to extend its functionality.

When provisioning your handler, Caddy uses reflection to discover and instantiate your MyCustomLogger struct. The Provision method is where you’d typically set up any necessary resources, like the http.Client in this example. The ServeHTTP method is the core of the handler, where you define what happens when a request matches.

The httpcaddyfile.RequestListener helper is crucial for integrating your handler with Caddyfile directives. It translates the directive’s arguments into your handler’s configuration and returns a function that Caddy can use to wrap the request handling pipeline.

Most people don’t realize that Caddy’s plugin system is highly flexible. You can create not only new handlers but also new loggers, templates, and even entirely new protocols by implementing the appropriate Caddy interfaces and registering them with caddy.RegisterModule. The caddyconfig.Dispenser is a powerful tool for parsing Caddyfile directives, allowing for complex configurations.

The next step in extending Caddy might be creating a custom Caddyfile directive that accepts more complex configurations, perhaps using JSON within the Caddyfile.

Want structured learning?

Take the full Caddy course →