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:624—go srv.server.Serve(ln)and:791-792—go stopServer(server)/go stopH3Server(server) - Assessment: Clean separation — the goroutine boundary matches the server lifecycle. Count is low (26 total
go funcusages 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 usesmake(chan os.Signal, 1). - Example:
modules/caddytls/tls.go:821—t.storageCleanStop = make(chan struct{}),sigtrap_posix.go:37— OS signal channel,modules/caddyhttp/reverseproxy/streaming.go:220—errc := 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.Contextwrapscontext.Context; its cancel function is invoked on config swap or process shutdown. All long-running server loops respect the underlying context. - Count: 72
context.Contextusages, 23select {}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
caddyhttpusesync.Onceto register Prometheus descriptors exactly once even if multiple server instances are created.sync.OnceValueused for deferred TLS connection state computation per-request. - Example:
modules/caddyhttp/app.go:489—getTlsConStateFunc := sync.OnceValue(func() *tls.ConnectionState { ... }),modules/caddyhttp/metrics.go:70—init 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.Mapkeyed by upstream withatomic.Int64values — 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.goimplementsfakeCloseListener/fakeClosePacketConn— wrappers that prevent the underlyingnet.Listenerfrom being closed when a module callsClose(). The closed state is tracked withint32andatomic.CompareAndSwapInt32. - Example:
listen.go:134,178—atomic.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:468—rate.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.Errorfwith%wwrapping; custom typed errors for domain-specific needs. - Error types defined:
APIError(admin.go:1368) — structured HTTP error withHTTPStatusandMessagefields,json-serializable for admin API responsesHandlerError(modules/caddyhttp/errors.go:57) — wraps an error with HTTP status code and request ID for error middleware propagationDialError(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 decisionsStaticError(modules/caddyhttp/staticerror.go:37) — handler that always returns a configured static error responseconfigLoadError(caddytest/caddytest.go:105) — test helper onlyexitError(cmd/cobra.go:161) — carries exit code through cobra’s error return
- Wrapping approach:
fmt.Errorf("context: %w", err)is universal. Nopkg/errors. errors.Is/errors.Asusage: 63 occurrences — modern error introspection is well-adopted.- Examples:
caddytest/caddytest.go:270—errors.Is(err, fs.ErrNotExist)for certificate file checksmodules/caddyhttp/reverseproxy/reverseproxy.go:1607—errors.As(err, &dialErr)to detectDialErrorand 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 forfunc With...ortype Option). - Mechanism: Each module struct has exported fields with
json:"..."tags. The module loader callsjson.Unmarshal(rawJSON, &moduleInstance)after constructing the module viaModuleInfo.New().StrictUnmarshalJSONrejects unknown fields, giving fast feedback on config errors. - Example: A handler module declares:The
type FileServer struct { Root string `json:"root,omitempty"` Hide []string `json:"hide,omitempty"` // ... }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.Contextin itsProvision(ctx caddy.Context) errormethod. ctx.App("http")retrieves a running app instance (with type assertion by the caller).ctx.LoadModule(owner, fieldName)loads a sub-module from ajson.RawMessagefield — the parent module is the “owner” of the sub-module’s lifetime.- Module instances are stored in
ctx.moduleInstancesmap;caddy.Context.Clean()iterates this and callsCleanup()on eachCleanerUpper.
- Every module that needs sibling modules receives a
- Assessment: The
caddy.Contextacts 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. Thexcaddybuild 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.Provisioner—Provision(ctx) error— called after JSON unmarshalcaddy.Validator—Validate() error— called after Provisioncaddy.CleanerUpper—Cleanup() error— called on config swap/shutdowncaddy.App—Start() / Stop() error— top-level application lifecyclecaddy.AdminRouter—Routes() []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.Poolwas 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.VarsCtxKey→map[string]any(per-request variables set by handler modules)http.LocalAddrContextKey(stdlib)- Assessment: The
Replacerapproach 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+OnceValueuses 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.