Skip to main content
This tutorial walks you through creating a complete Caddy HTTP handler plugin that adds custom headers and logs request information.

What We’re Building

We’ll create a plugin called requestinfo that:
  • Adds custom headers to HTTP responses
  • Logs request details (method, path, client IP)
  • Supports Caddyfile configuration
  • Implements the full module lifecycle

Prerequisites

  • Go 1.25.0 or newer
  • Basic understanding of Go and HTTP
  • Familiarity with Caddy configuration

Step 1: Create the Module Structure

Create a new directory for your plugin:
mkdir -p caddy-requestinfo
cd caddy-requestinfo
go mod init github.com/yourusername/caddy-requestinfo
Create the main file requestinfo.go:
package requestinfo

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

    "github.com/caddyserver/caddy/v2"
    "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
    "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
    "github.com/caddyserver/caddy/v2/modules/caddyhttp"
    "go.uber.org/zap"
)

Step 2: Define the Module Structure

Based on the pattern from modules/caddyhttp/staticresp.go:88, define your handler:
// RequestInfo is a handler that logs request information
// and adds custom headers
type RequestInfo struct {
    // HeaderName is the name of the header to add
    HeaderName string `json:"header_name,omitempty"`

    // HeaderValue is the value of the header to add
    HeaderValue string `json:"header_value,omitempty"`

    // LogEnabled controls whether to log request info
    LogEnabled bool `json:"log_enabled,omitempty"`

    logger *zap.Logger
}

Step 3: Implement the Module Interface

Implement CaddyModule() from modules.go:54:
// CaddyModule returns the Caddy module information
func (RequestInfo) CaddyModule() caddy.ModuleInfo {
    return caddy.ModuleInfo{
        ID:  "http.handlers.requestinfo",
        New: func() caddy.Module { return new(RequestInfo) },
    }
}
The module ID http.handlers.requestinfo places this in the HTTP handlers namespace.

Step 4: Implement the Lifecycle Methods

Provision

Implement Provision() based on modules/caddyhttp/tracing/module.go:48:
// Provision implements caddy.Provisioner
func (r *RequestInfo) Provision(ctx caddy.Context) error {
    r.logger = ctx.Logger()

    // Set default values
    if r.HeaderName == "" {
        r.HeaderName = "X-Request-Info"
    }
    if r.HeaderValue == "" {
        r.HeaderValue = "Processed"
    }

    r.logger.Info("request info handler provisioned",
        zap.String("header_name", r.HeaderName),
        zap.Bool("logging", r.LogEnabled))

    return nil
}

Validate

// Validate implements caddy.Validator
func (r *RequestInfo) Validate() error {
    if r.HeaderName == "" {
        return fmt.Errorf("header_name cannot be empty")
    }
    return nil
}

Step 5: Implement the HTTP Handler

Implement ServeHTTP() to handle requests:
// ServeHTTP implements caddyhttp.MiddlewareHandler
func (r RequestInfo) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
    // Log request information if enabled
    if r.LogEnabled {
        r.logger.Info("request received",
            zap.String("method", req.Method),
            zap.String("path", req.URL.Path),
            zap.String("remote_addr", req.RemoteAddr),
            zap.String("user_agent", req.UserAgent()),
            zap.Time("timestamp", time.Now()))
    }

    // Add custom header
    w.Header().Set(r.HeaderName, r.HeaderValue)

    // Continue to the next handler
    return next.ServeHTTP(w, req)
}

Step 6: Add Caddyfile Support

Implement UnmarshalCaddyfile() based on modules/caddyhttp/staticresp.go:141:
// UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax:
//
//  requestinfo {
//      header_name <name>
//      header_value <value>
//      log_enabled
//  }
func (r *RequestInfo) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
    d.Next() // consume directive name

    // No same-line arguments expected
    if d.NextArg() {
        return d.ArgErr()
    }

    // Parse block
    for d.NextBlock(0) {
        switch d.Val() {
        case "header_name":
            if !d.NextArg() {
                return d.ArgErr()
            }
            r.HeaderName = d.Val()

        case "header_value":
            if !d.NextArg() {
                return d.ArgErr()
            }
            r.HeaderValue = d.Val()

        case "log_enabled":
            r.LogEnabled = true

        default:
            return d.Errf("unrecognized subdirective: %s", d.Val())
        }
    }

    return nil
}

Step 7: Register the Module

Add registration in the init() function from modules/caddyhttp/tracing/module.go:15:
func init() {
    caddy.RegisterModule(RequestInfo{})
    httpcaddyfile.RegisterHandlerDirective("requestinfo", parseCaddyfile)
}

func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
    var m RequestInfo
    err := m.UnmarshalCaddyfile(h.Dispenser)
    return &m, err
}

Step 8: Add Interface Guards

Add compile-time interface checks:
// Interface guards - compile-time verification
var (
    _ caddy.Provisioner           = (*RequestInfo)(nil)
    _ caddy.Validator             = (*RequestInfo)(nil)
    _ caddyhttp.MiddlewareHandler = (*RequestInfo)(nil)
    _ caddyfile.Unmarshaler       = (*RequestInfo)(nil)
)

Step 9: Build with xcaddy

Install xcaddy:
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
Build Caddy with your plugin:
xcaddy build \
    --with github.com/yourusername/caddy-requestinfo
For local development, use --with github.com/yourusername/caddy-requestinfo=./caddy-requestinfo

Step 10: Test Your Plugin

JSON Configuration

Create caddy.json:
{
  "apps": {
    "http": {
      "servers": {
        "example": {
          "listen": [":8080"],
          "routes": [
            {
              "handle": [
                {
                  "handler": "requestinfo",
                  "header_name": "X-Custom-Header",
                  "header_value": "Hello from plugin!",
                  "log_enabled": true
                },
                {
                  "handler": "static_response",
                  "body": "Plugin is working!"
                }
              ]
            }
          ]
        }
      }
    }
  }
}

Caddyfile Configuration

Create a Caddyfile:
:8080 {
    requestinfo {
        header_name X-Custom-Header
        header_value "Hello from plugin!"
        log_enabled
    }
    respond "Plugin is working!"
}

Run and Test

# Run with JSON config
./caddy run --config caddy.json

# Or with Caddyfile
./caddy run --config Caddyfile

# Test in another terminal
curl -v http://localhost:8080
You should see:
  • The custom header in the response
  • Request logs in the Caddy output (if log_enabled is true)

Complete Code

package requestinfo

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

    "github.com/caddyserver/caddy/v2"
    "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
    "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
    "github.com/caddyserver/caddy/v2/modules/caddyhttp"
    "go.uber.org/zap"
)

func init() {
    caddy.RegisterModule(RequestInfo{})
    httpcaddyfile.RegisterHandlerDirective("requestinfo", parseCaddyfile)
}

type RequestInfo struct {
    HeaderName  string `json:"header_name,omitempty"`
    HeaderValue string `json:"header_value,omitempty"`
    LogEnabled  bool   `json:"log_enabled,omitempty"`
    logger      *zap.Logger
}

func (RequestInfo) CaddyModule() caddy.ModuleInfo {
    return caddy.ModuleInfo{
        ID:  "http.handlers.requestinfo",
        New: func() caddy.Module { return new(RequestInfo) },
    }
}

func (r *RequestInfo) Provision(ctx caddy.Context) error {
    r.logger = ctx.Logger()
    if r.HeaderName == "" {
        r.HeaderName = "X-Request-Info"
    }
    if r.HeaderValue == "" {
        r.HeaderValue = "Processed"
    }
    return nil
}

func (r *RequestInfo) Validate() error {
    if r.HeaderName == "" {
        return fmt.Errorf("header_name cannot be empty")
    }
    return nil
}

func (r RequestInfo) ServeHTTP(w http.ResponseWriter, req *http.Request, next caddyhttp.Handler) error {
    if r.LogEnabled {
        r.logger.Info("request received",
            zap.String("method", req.Method),
            zap.String("path", req.URL.Path),
            zap.String("remote_addr", req.RemoteAddr),
            zap.Time("timestamp", time.Now()))
    }
    w.Header().Set(r.HeaderName, r.HeaderValue)
    return next.ServeHTTP(w, req)
}

func (r *RequestInfo) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
    d.Next()
    if d.NextArg() {
        return d.ArgErr()
    }
    for d.NextBlock(0) {
        switch d.Val() {
        case "header_name":
            if !d.NextArg() {
                return d.ArgErr()
            }
            r.HeaderName = d.Val()
        case "header_value":
            if !d.NextArg() {
                return d.ArgErr()
            }
            r.HeaderValue = d.Val()
        case "log_enabled":
            r.LogEnabled = true
        default:
            return d.Errf("unrecognized subdirective: %s", d.Val())
        }
    }
    return nil
}

func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
    var m RequestInfo
    err := m.UnmarshalCaddyfile(h.Dispenser)
    return &m, err
}

var (
    _ caddy.Provisioner           = (*RequestInfo)(nil)
    _ caddy.Validator             = (*RequestInfo)(nil)
    _ caddyhttp.MiddlewareHandler = (*RequestInfo)(nil)
    _ caddyfile.Unmarshaler       = (*RequestInfo)(nil)
)

Next Steps

Module Development

Deep dive into module architecture

Testing Guide

Learn how to test your plugins

xcaddy Tool

Master the xcaddy build tool

Contributing

Share your plugin with the community

Build docs developers (and LLMs) love