Caddy — Patterns#

Concurrency patterns#

Goroutine-per-server startup#

  • Usage: Each HTTP/H2/H3 server listener is launched in a dedicated goroutine from App.Start(). Shutdown goroutines are also launched per-server.
  • Example: modules/caddyhttp/app.go:624go srv.server.Serve(ln) and :791-792go stopServer(server) / go stopH3Server(server)
  • Assessment: Clean separation — the goroutine boundary matches the server lifecycle. Count is low (26 total go func usages across the codebase), so concurrency is not overused.

Done/stop channels#

  • Usage: Lifecycle signals use make(chan struct{}) stop channels rather than context cancellation in several places (TLS storage cleaner, session ticket rotation). Signal handling uses make(chan os.Signal, 1).
  • Example: modules/caddytls/tls.go:821t.storageCleanStop = make(chan struct{}), sigtrap_posix.go:37 — OS signal channel, modules/caddyhttp/reverseproxy/streaming.go:220errc := make(chan error, 2) for bidirectional copy fan-in
  • Assessment: Mixed: newer code (admin server shutdown) uses context.WithTimeoutCause; older subsystems (TLS) use explicit stop channels. Both are idiomatic; the inconsistency is minor.

Fan-in error channel (bidirectional proxy)#

  • Usage: Reverse proxy tunnel streaming uses errc := make(chan error, 2) to collect errors from both copy goroutines, then returns the first non-nil error.
  • Example: modules/caddyhttp/reverseproxy/streaming.go:220
  • Assessment: Classic fan-in for “first error wins” semantics. Buffered to avoid goroutine leaks if the parent returns early.

Context cancellation / graceful shutdown#

  • Usage: caddy.Context wraps context.Context; its cancel function is invoked on config swap or process shutdown. All long-running server loops respect the underlying context.
  • Count: 72 context.Context usages, 23 select {} sites.
  • Assessment: Context propagation is thorough. The config-lifecycle context (caddy.Context) cleanly separates module lifetime from request lifetime.

sync.Once for lazy initialization#

  • Usage: Metrics registrations in caddyhttp use sync.Once to register Prometheus descriptors exactly once even if multiple server instances are created. sync.OnceValue used for deferred TLS connection state computation per-request.
  • Example: modules/caddyhttp/app.go:489getTlsConStateFunc := sync.OnceValue(func() *tls.ConnectionState { ... }), modules/caddyhttp/metrics.go:70init sync.Once
  • Assessment: Correct and idiomatic. sync.OnceValue (Go 1.21) is a nice modern touch for the per-request case.

sync.Map + atomic for hot-path counters#

  • Usage: In-flight request tracking for reverse proxy uses sync.Map keyed by upstream with atomic.Int64 values — specifically documented in the source as a lock-free hot path.
  • Example: modules/caddyhttp/reverseproxy/reverseproxy.go:50-51 — explicit comment explains the design decision
  • Assessment: Well-justified: avoids a global mutex on every proxied request. The comment documenting why is exemplary.

Atomic CAS for fake-close listener#

  • Usage: listen.go implements fakeCloseListener / fakeClosePacketConn — wrappers that prevent the underlying net.Listener from being closed when a module calls Close(). The closed state is tracked with int32 and atomic.CompareAndSwapInt32.
  • Example: listen.go:134,178atomic.LoadInt32(&fcl.closed) / atomic.CompareAndSwapInt32(&fcl.closed, 0, 1)
  • Assessment: Necessary complexity for Caddy’s live-reload guarantee — listeners must survive config swaps. Atomic CAS ensures Close() is idempotent without a mutex on the hot Accept() path.

Rate-limited connection acceptance#

  • Usage: listeners.go:468rate.NewLimiter(1000, 1000) applied at the listener level to prevent connection storms.
  • Assessment: Simple and effective; placed at the right abstraction layer (listener wrapper) so it applies to all protocols.

Worker pool#

  • Usage: Not present. Caddy uses one goroutine per server, not a pool of workers. Request handling is delegated to the Go net/http runtime which manages its own goroutine-per-connection model.

Error handling#

  • Style: Mixed — primarily fmt.Errorf with %w wrapping; custom typed errors for domain-specific needs.
  • Error types defined:
    • APIError (admin.go:1368) — structured HTTP error with HTTPStatus and Message fields, json-serializable for admin API responses
    • HandlerError (modules/caddyhttp/errors.go:57) — wraps an error with HTTP status code and request ID for error middleware propagation
    • DialError (modules/caddyhttp/reverseproxy/reverseproxy.go:1607) — typed wrapper to distinguish dial failures from upstream errors (used in retry logic)
    • roundtripSucceededError — private sentinel to distinguish “upstream responded” from “we failed to proxy” for passthrough decisions
    • StaticError (modules/caddyhttp/staticerror.go:37) — handler that always returns a configured static error response
    • configLoadError (caddytest/caddytest.go:105) — test helper only
    • exitError (cmd/cobra.go:161) — carries exit code through cobra’s error return
  • Wrapping approach: fmt.Errorf("context: %w", err) is universal. No pkg/errors.
  • errors.Is / errors.As usage: 63 occurrences — modern error introspection is well-adopted.
  • Examples:
    • caddytest/caddytest.go:270errors.Is(err, fs.ErrNotExist) for certificate file checks
    • modules/caddyhttp/reverseproxy/reverseproxy.go:1607errors.As(err, &dialErr) to detect DialError and decide retry behavior

Configuration pattern#

  • Approach: JSON struct tags + custom caddy:"namespace=... inline_key=..." struct tags. No functional options anywhere in the codebase (zero matches for func With... or type Option).
  • Mechanism: Each module struct has exported fields with json:"..." tags. The module loader calls json.Unmarshal(rawJSON, &moduleInstance) after constructing the module via ModuleInfo.New(). StrictUnmarshalJSON rejects unknown fields, giving fast feedback on config errors.
  • Example: A handler module declares:
    type FileServer struct {
        Root    string `json:"root,omitempty"`
        Hide    []string `json:"hide,omitempty"`
        // ...
    }
    The caddy:"..." tag on parent structs (e.g. HandlersRaw json.RawMessage \caddy:“namespace=http.handlers inline_key=handler”``) drives dispatch to the right module.
  • Assessment: Config-as-struct is extremely consistent across all modules. The namespace dispatch tag is clever — it moves the “which module?” logic from Go code into the struct tag and JSON data.

Dependency injection#

  • Approach: Manual / context-passing. No wire, dig, or fx.
  • Evidence:
    • Every module that needs sibling modules receives a caddy.Context in its Provision(ctx caddy.Context) error method.
    • ctx.App("http") retrieves a running app instance (with type assertion by the caller).
    • ctx.LoadModule(owner, fieldName) loads a sub-module from a json.RawMessage field — the parent module is the “owner” of the sub-module’s lifetime.
    • Module instances are stored in ctx.moduleInstances map; caddy.Context.Clean() iterates this and calls Cleanup() on each CleanerUpper.
  • Assessment: The caddy.Context acts as a narrow service locator, not a full DI container. The pattern is explicit, searchable, and avoids reflection magic. The cost is boilerplate type assertions; the benefit is clarity about what depends on what.

Other notable patterns#

init()-driven self-registration#

Every module package has a func init() that calls caddy.RegisterModule(MyModule{}). There are 112 init functions across the codebase and 136 RegisterModule calls. modules/standard/imports.go is the single aggregation point: it blank-imports every standard module package, ensuring all init() calls fire before main().

  • Assessment: This is Caddy’s core extensibility mechanism. It is simple and reliable but has the standard init() drawback: all modules are registered unconditionally at startup, even if unused. The xcaddy build tool mitigates this by letting users compose exactly the modules they need.

Compile-time interface satisfaction checks#

125 occurrences of var _ caddy.SomeInterface = (*ConcreteType)(nil) scattered throughout the codebase. Nearly every module file has at least one.

  • Example: modules/caddyhttp/fileserver/staticfiles.go:821_ caddy.Provisioner = (*FileServer)(nil)
  • Assessment: Best practice. Catches interface drift at compile time rather than at Provision() call time. The density (125 checks) reflects how many optional lifecycle interfaces modules can implement.

Lifecycle capability interfaces (optional behavior)#

Rather than requiring all modules to implement a large interface, Caddy defines small capability interfaces that modules opt into:

  • caddy.ProvisionerProvision(ctx) error — called after JSON unmarshal
  • caddy.ValidatorValidate() error — called after Provision
  • caddy.CleanerUpperCleanup() error — called on config swap/shutdown
  • caddy.AppStart() / Stop() error — top-level application lifecycle
  • caddy.AdminRouterRoutes() []AdminRoute — adds admin API routes

This is the Interface Segregation Principle applied to a plugin lifecycle. Modules implement only the capabilities they need.

UsagePool — reference-counted shared resource pool#

usagepool.go implements a thread-safe map with reference counting for shared, expensive resources (e.g., log writers). LoadOrNew atomically creates the resource if absent, increments refcount otherwise. Delete decrements and calls Destruct() when the count reaches zero.

  • Assessment: Solves a real problem cleanly: multiple modules may share a single log file or TLS cert cache; the pool ensures it’s opened once and closed only when all users are gone. The design is documented in detail in the source, including why sync.Pool was insufficient.

Context values for request-scoped data#

context.WithValue is used to attach request-scoped state:

  • caddy.ReplacerCtxKey*caddy.Replacer (variable substitution engine, attached at request entry)
  • caddyhttp.VarsCtxKeymap[string]any (per-request variables set by handler modules)
  • http.LocalAddrContextKey (stdlib)
  • Assessment: The Replacer approach is particularly notable: rather than thread-local storage, each request carries its own variable map via context. This is clean and safe but requires discipline to avoid the anti-pattern of overusing context values for “hidden” dependencies.

Type switches for JSON dispatch#

admin.go:1198, listen.go:207, caddyconfig/httpcaddyfile/httptype.go:1373 all use switch v := x.(type) to dispatch on concrete types after loading from interfaces. This is expected in a system with dynamic module loading but is kept to specific dispatch points rather than scattered throughout.

Replacer (variable substitution engine)#

replacer.go implements caddy.Replacer — a context-scoped variable expansion engine (like ${var} in shell). Each request gets a fresh Replacer with providers registered for request fields (method, path, headers, TLS state). Handlers and config values can reference {http.request.uri} etc.

  • Assessment: A self-contained, well-isolated subsystem. The sync.Once + OnceValue uses around TLS state extraction show careful performance attention.

No generics#

No generic functions or types are used in the main Caddy codebase (beyond what the standard library provides). The grep for func.*\[.*\] returned only false positives (slice parameters). This is consistent with the project’s Go 1.20+ minimum and the module registry design — type-safe generics aren’t needed when the interface/assertion model suffices.

No functional options#

Despite being a widely-adopted Go pattern, Caddy uses none. Configuration is entirely JSON-driven. This is a deliberate architectural choice: the config lives in JSON files and the admin API; functional options would create a parallel configuration mechanism at odds with the live-reload model.