How Caddy's Modular App Architecture Works: A Deep Dive into the Plugin System

Caddy's modular app architecture is a runtime plugin system where every functional component—from HTTP servers to TLS issuers—implements the caddy.Module interface, registers via caddy.RegisterModule() in an init() function, and is lazily loaded from JSON configuration through the Context.App() method.

Caddy is not a monolithic web server. Instead, it is built around a flexible module system that allows developers to extend core functionality without modifying the main codebase. This architecture enables dynamic discovery, instantiation, and wiring of components at runtime, whether you are configuring the built-in HTTP app, a custom storage backend, or a third-party middleware plugin.

Core Components of the Module System

Module Definition and Registration

Every Caddy module must implement the caddy.Module interface defined in modules.go. This interface requires only a single method: CaddyModule(), which returns a caddy.ModuleInfo struct containing a unique module ID and a constructor function.

In modules.go (lines 29-38), the ModuleInfo struct is defined as:

type ModuleInfo struct {
    ID    ModuleID
    New   func() Module
    // ...
}

Modules register themselves by calling caddy.RegisterModule() inside an init() function. As shown in modules.go (lines 124-131), this function stores the ModuleInfo in a global map protected by modulesMu:

func RegisterModule(m Module) {
    modulesMu.Lock()
    defer modulesMu.Unlock()
    info := m.CaddyModule()
    modules[info.ID] = info
}

Module IDs and Namespaces

Module IDs follow the format <namespace>.<name>, parsed in modules.go (lines 85-101). The namespace is everything before the last dot, while the final label represents the module name. Top-level apps use an empty namespace, such as http or tls, while submodules use nested namespaces like http.handlers.reverse_proxy.

Configuration and App Loading

The Config Structure

The root configuration object, defined in caddy.go (lines 71-82), uses a Config struct that holds raw JSON for each application:

type Config struct {
    AppsRaw ModuleMap `json:"apps,omitempty"`
    StorageRaw json.RawMessage `json:"storage,omitempty"`
    // ...
}

ModuleMap is a map where keys are module names (e.g., "http") and values are raw JSON messages. This design allows Caddy to defer unmarshaling until the specific app is actually needed.

Lazy Loading via Context

When Caddy needs to activate an application, it invokes ctx.App(name) defined in context.go (lines 85-115). This method implements lazy loading:

  1. Check if the app already exists in cfg.apps (the cache)
  2. If missing, retrieve raw JSON from cfg.AppsRaw[name]
  3. Call LoadModuleByID(name, raw) to instantiate the module
  4. Store the result in cfg.apps and clear the raw JSON entry for GC

This pattern ensures that only required applications consume memory and CPU resources during startup.

Inline Module Loading

For configurations where the module name is embedded within the object rather than as a map key, context.go (lines 71-80) provides loadModuleInline. This helper extracts the module identifier from a struct field and loads the concrete type via LoadModuleByID, enabling arrays of modules and multiple instances of the same module type with different configurations.

The Application Lifecycle

Provisioning

After instantiation, if a module implements the caddy.Provisioner interface, Caddy calls its Provision(ctx) method. This is where apps perform setup that requires access to other modules or the file system. For example, the HTTP app in modules/caddyhttp/app.go uses provisioning to set up route handlers and connect to the TLS app via ctx.App("tls").

Starting and Stopping

All apps must implement the caddy.App interface defined in caddy.go (lines 97-101):

type App interface {
    Start() error
    Stop() error
}

Once provisioning completes, Caddy invokes Start() to begin the application's main operation, such as opening network listeners. During configuration reloads or shutdown, Caddy calls Stop() to gracefully terminate the application.

HTTP App Example

The built-in HTTP server demonstrates this architecture in practice. Located in modules/caddyhttp/app.go, it defines an App struct that implements caddy.Module, caddy.Provisioner, and caddy.App. During provisioning, it unmarshals server configurations and sets up the middleware chain. When started, it begins listening on configured ports, and when stopped, it shuts down the HTTP servers gracefully.

Building a Custom App

You can extend Caddy by creating a custom module that follows the same patterns as built-in apps. Here is a minimal example:

package myapp

import (
	"fmt"
	"github.com/caddyserver/caddy/v2"
)

// Define the app type implementing caddy.App
type MyApp struct {
	Message string `json:"message,omitempty"`
}

// Register the module on import
func init() { caddy.RegisterModule(MyApp{}) }

// Provide module metadata
func (MyApp) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "myapp",
		New: func() caddy.Module { return new(MyApp) },
	}
}

// Validate configuration
func (a *MyApp) Provision(ctx caddy.Context) error {
	if a.Message == "" {
		return fmt.Errorf("message is required")
	}
	return nil
}

// Start the application
func (a *MyApp) Start() error {
	fmt.Println("MyApp started:", a.Message)
	return nil
}

// Stop the application
func (a *MyApp) Stop() error {
	fmt.Println("MyApp stopped")
	return nil
}

To use this app, include it in your Caddy JSON configuration:

{
  "apps": {
    "myapp": {
      "message": "Hello from MyApp"
    }
  }
}

When Caddy loads this configuration, it automatically executes the registration, loading, provisioning, and startup sequence.

Summary

  • Caddy's modular app architecture relies on the caddy.Module interface, where each component implements CaddyModule() to return metadata including a unique ID and constructor.
  • Registration occurs in init() functions via caddy.RegisterModule(), storing module info in a global map in modules.go.
  • Configuration uses a deferred loading pattern where caddy.Config holds raw JSON in AppsRaw, and Context.App() lazily instantiates apps via LoadModuleByID in context.go.
  • Lifecycle follows three phases: Provisioning (setup and validation via Provisioner interface), Starting (opening resources via App.Start()), and Stopping (cleanup via App.Stop()).
  • Extensibility allows developers to create custom apps by implementing the same interfaces used by built-in modules like the HTTP app in modules/caddyhttp/app.go.

Frequently Asked Questions

What is the minimum interface required to create a Caddy module?

At minimum, a type must implement the caddy.Module interface defined in modules.go, which requires a single method: CaddyModule() caddy.ModuleInfo. This method must return a struct containing a unique ModuleID and a New constructor function. If you want your module to function as a top-level app, it must also implement the caddy.App interface with Start() and Stop() methods.

How does Caddy handle module dependencies during configuration loading?

Caddy uses lazy loading through the Context.App() method in context.go. When a module needs to access another app (for example, when the HTTP app needs the TLS app), it calls ctx.App("tls"). If the app is not yet loaded, Caddy retrieves the raw JSON from Config.AppsRaw, instantiates the module via LoadModuleByID, caches it in cfg.apps, and then returns it. This ensures dependencies are loaded only when needed and prevents circular initialization issues.

Can multiple instances of the same module type be used in a single configuration?

Yes, Caddy supports multiple instances through inline module loading. While top-level apps use the AppsRaw map (where keys are unique), you can embed module configurations directly within other structures using the inline loading mechanism handled by loadModuleInline in context.go. This allows arrays of modules or multiple instances of the same handler type with different configurations, such as multiple reverse proxy upstreams with distinct settings.

Where does module registration actually happen in the Caddy source code?

Module registration occurs in the init() functions of individual module packages, but the central registry logic resides in modules.go. The RegisterModule() function (lines 124-131) stores module metadata in a global modules map protected by modulesMu. Additionally, modules.go (often auto-generated or maintained in cmd/caddy/main.go in older versions) imports all module packages to ensure their init() functions execute, populating the registry before the main configuration loads.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →