End to end: a client sends an HTTP request to a running Caddy server
This module follows a single HTTP request from the moment a client connects to a running Caddy server through to the bytes it gets back. You'll trace the exact call path: App.Start wiring a caddyhttp.Server into a real net/http.Server, Server.ServeHTTP preparing placeholders, wrapping the response for access logging and guaranteeing ACME HTTP-challenge handling, the compiled Route chain deciding which middleware runs via matcher sets, and finally the file_server terminal handler resolving a path on disk and streaming it back. By the end you can point to the specific function responsible for each phase of any request Caddy serves.
App.Start: wiring a configured Server to a real net/http.Server
modules/caddyhttp/app.go · lines 472–492caddyhttp.App holds a map of configured Server values (app.Servers). This is the module's own config type — a bundle of listen addresses, routes and timeouts. For each one, Start allocates a genuinely standard-library http.Server. Nothing here is Caddy-special yet; this is where Caddy hands off to Go's net/http package to do the actual socket work.
The struct literal copies Caddy's own config fields (srv.ReadTimeout, srv.MaxHeaderBytes, ...) onto the equivalent http.Server fields. This is a translation layer: the declarative JSON/Caddyfile config the user wrote becomes the concrete knobs Go's HTTP implementation understands.
This is the actual entry point of the whole system: Handler: srv tells the standard library that *this* caddyhttp.Server value is what should receive every decoded request. Think of http.Server as a switchboard operator — it only knows how to accept connections and parse HTTP framing; every call it answers gets forwarded to one fixed extension. That extension is srv.ServeHTTP, which the next widget walks through.
ConnContext runs once per accepted connection (not per request) and stashes a lazily-evaluated TLS-state getter on the connection's context whenever a listener wrapper hides the raw *tls.Conn. This is set up here, at connection-accept time, but it's read later — inside Server.ServeHTTP — to fill in r.TLS for requests whose TLS state isn't directly visible.
for srvName, srv := range app.Servers { srv.server = &http.Server{ ReadTimeout: time.Duration(srv.ReadTimeout), ReadHeaderTimeout: time.Duration(srv.ReadHeaderTimeout), WriteTimeout: time.Duration(srv.WriteTimeout), IdleTimeout: time.Duration(srv.IdleTimeout), MaxHeaderBytes: srv.MaxHeaderBytes, Handler: srv, ErrorLog: serverLogger, Protocols: new(http.Protocols), ConnContext: func(ctx context.Context, c net.Conn) context.Context { if nc, ok := c.(interface{ tlsNetConn() net.Conn }); ok { getTlsConStateFunc := sync.OnceValue(func() *tls.ConnectionState { tlsConnState := nc.tlsNetConn().(connectionStater).ConnectionState() return &tlsConnState }) ctx = context.WithValue(ctx, tlsConnectionStateFuncCtxKey, getTlsConStateFunc) } return ctx }, }
Server.ServeHTTP: the first Caddy code every request touches
modules/caddyhttp/server.go · lines 454–501caddy.NewReplacer() creates a per-request key/value resolver — a *Replacer* — and PrepareRequest attaches it to the request's context. Every {http.request.*} placeholder documented at the top of caddyhttp.App (like {http.request.method} or {http.request.uri.path}) is answered by looking a key up in this exact object. This is the concrete mechanism behind 'placeholders': not string substitution in config, but a live lookup table populated fresh for each request.
Before any handler runs, ServeHTTP clones the request into loggableReq — a lazy zap field that only gets encoded if something actually logs it. Cloning happens now, before the handler chain can mutate the request, so the eventual access-log entry reflects what the client actually sent, not what some middleware rewrote it into.
If access logging is enabled for this host, the response writer is swapped for a wrec (a ResponseRecorder that remembers the status code and byte count once something writes to it), and the request body is wrapped so bytes read from it can be counted too. The defer s.logRequest(...) line is the key trick: it doesn't log anything yet — it schedules the log write to happen *after* the entire handler chain below has run and produced a real response, which is the only point at which there's a status and size to report.
s.tlsApp.HandleHTTPChallenge checks whether this request is an ACME HTTP-01 challenge — the request a certificate authority makes to a well-known path to prove domain ownership before issuing or renewing a certificate. This check runs unconditionally, before any user-configured route is even consulted, and returns immediately if it handled the request. That ordering is deliberate: automatic HTTPS certificate issuance must keep working even if a site's own routes would otherwise 404 or redirect that exact path.
Only now does control pass into s.serveHTTP, the method that runs header sanitization and then invokes the compiled handler chain. Everything up to this line was preparation and bookkeeping; this is the actual dispatch into user-configured behavior, and duration measures the whole round trip once it returns.
// prepare internals of the request for the handler pipeline repl := caddy.NewReplacer() r = PrepareRequest(r, repl, w, s) // clone the request for logging purposes before it enters any handler chain; // this is necessary to capture the original request in case it gets modified // during handling (cloning the request and using .WithLazy is considerably // faster than using .With, which will JSON-encode the request immediately) shouldLogCredentials := s.Logs != nil && s.Logs.ShouldLogCredentials loggableReq := zap.Object("request", LoggableHTTPRequest{ Request: r.Clone(r.Context()), ShouldLogCredentials: shouldLogCredentials, }) errLog := s.errorLogger.WithLazy(loggableReq) var duration time.Duration if s.shouldLogRequest(r) { wrec := NewResponseRecorder(w, nil, nil) w = wrec // wrap the request body in a LengthReader // so we can track the number of bytes read from it var bodyReader *lengthReader if r.Body != nil { bodyReader = &lengthReader{Source: r.Body} r.Body = bodyReader // should always be true, private interface can only be referenced in the same package if setReadSizer, ok := wrec.(interface{ setReadSize(*int) }); ok { setReadSizer.setReadSize(&bodyReader.Length) } } // capture the original version of the request accLog := s.accessLogger.WithLazy(loggableReq) defer s.logRequest(accLog, r, wrec, &duration, repl, bodyReader, shouldLogCredentials) } // guarantee ACME HTTP challenges; handle them separately from any user-defined handlers if s.tlsApp.HandleHTTPChallenge(w, r) { duration = time.Since(start) return } err := s.serveHTTP(w, r) duration = time.Since(start)
routes.go: a matcher set qualifies a Route, then its Handlers chain like middleware
modules/caddyhttp/routes.go · lines 256–326wrapRoute returns a Middleware — Caddy's own type alias for func(Handler) Handler. Calling it with a next handler produces a new Handler closure specific to this one Route. This is how a whole list of routes becomes one callable chain: each route becomes a link that knows what comes after it.
At request time, the route's matcher sets are finally evaluated: route.MatcherSets.AnyMatchWithError(req) runs the live *http.Request (its Host header, path, method, whatever the config specified) against the route's condition. This is the route's 'if' statement, and it only runs now — per request — not once at startup, because the same route object is reused for every request.
If the matcher didn't match, this route does nothing but call nextCopy — the handler for whatever comes after it (the next Route in the list, or an eventual fallback). Picture each configured Route as a bouncer at a door in a hallway: if your ticket doesn't match this door's rule, you're waved straight on to the next door, not stopped.
A matched route with Terminal: true replaces nextCopy with emptyHandler (or errorEmptyHandler if already inside error handling) before building its own chain. That guarantees no route *after* this one in the list runs, even if this route's own handlers politely call their next — terminal is enforced structurally, not by convention.
This is the middleware chain the route doc comment describes: route.middleware (one wrapped entry per configured Handler) is folded backwards over nextCopy, so the last handler in the list wraps the first. Each element is a MiddlewareHandler — a handler whose ServeHTTP(w, r, next) explicitly receives the next handler as an argument, rather than the plain error-returning Handler used elsewhere. The result: requests flow down the Handlers list in configured order, and because each wrap is a closure around the next, responses (and any writes) flow back up through the same handlers in reverse.
Finally the fully-built chain (or the plain pass-through, if the route didn't match) is actually invoked. For a route whose last Handler is file_server, this is the call that eventually lands in the terminal handler covered next.
func wrapRoute(route Route) Middleware { return func(next Handler) Handler { return HandlerFunc(func(rw http.ResponseWriter, req *http.Request) error { // TODO: Update this comment, it seems we've moved the copy into the handler? // copy the next handler (it's an interface, so it's just // a very lightweight copy of a pointer); this is important // because this is a closure to the func below, which // re-assigns the value as it compiles the middleware stack; // if we don't make this copy, we'd affect the underlying // pointer for all future request (yikes); we could // alternatively solve this by moving the func below out of // this closure and into a standalone package-level func, // but I just thought this made more sense nextCopy := next // route must match at least one of the matcher sets matches, err := route.MatcherSets.AnyMatchWithError(req) if err != nil { // allow matchers the opportunity to short circuit // the request and trigger the error handling chain return err } if !matches { // call the next handler, and skip this one, // since the matcher didn't match return nextCopy.ServeHTTP(rw, req) } // if route is part of a group, ensure only the // first matching route in the group is applied if route.Group != "" { groups := req.Context().Value(routeGroupCtxKey).(map[string]struct{}) if _, ok := groups[route.Group]; ok { // this group has already been // satisfied by a matching route return nextCopy.ServeHTTP(rw, req) } // this matching route satisfies the group groups[route.Group] = struct{}{} } // make terminal routes terminate if route.Terminal { if _, ok := req.Context().Value(ErrorCtxKey).(error); ok { nextCopy = errorEmptyHandler } else { nextCopy = emptyHandler } } // compile this route's handler stack for _, middleware := range slices.Backward(route.middleware) { nextCopy = middleware(nextCopy) } // Apply metrics instrumentation once for the entire route, // rather than wrapping each individual handler. This avoids // redundant metrics collection that caused significant CPU // overhead (see issue #4644). if route.metrics != nil { nextCopy = newMetricsInstrumentedRoute( route.metricsCtx, route.handlerName, nextCopy, route.metrics, ) } return nextCopy.ServeHTTP(rw, req) }) }}
file_server: resolving the request path against the filesystem
modules/caddyhttp/fileserver/staticfiles.go · lines 269–319file_server's signature — ServeHTTP(w, r, next) — is a concrete instance of the MiddlewareHandler contract every route Handler implements. It pulls the same *caddy.Replacer off the request context that PrepareRequest installed all the way back in Server.ServeHTTP: one Replacer, threaded through every handler in the chain.
fsrv.Root is typically a placeholder string like {http.vars.root}; repl.ReplaceAll resolves it through that same Replacer into a concrete directory path. This is placeholders doing real work: config stays declarative and reusable, while each request gets its own resolved root.
caddyhttp.SanitizedPathJoin(root, r.URL.Path) is where the request path actually meets the filesystem: it joins the resolved site root with the URL path, cleaning .. segments and separators so a request can't walk outside the root. This single call is the answer to 'how does file_server find the file' — everything before it was just computing the two inputs.
fs.Stat checks whether the resolved path exists on the target fs.FS (by default the local disk, but pluggable). Rather than let a raw OS error leak to the client, mapDirOpenError and the branches below translate it into one of a small set of precise HTTP outcomes: 404 if genuinely missing, 400 for an invalid path, 403 for a permission error, 500 otherwise.
func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) if runtime.GOOS == "windows" { // reject paths with Alternate Data Streams (ADS) if strings.Contains(r.URL.Path, ":") { return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal ADS path")) } // reject paths with "8.3" short names trimmedPath := strings.TrimRight(r.URL.Path, ". ") // Windows ignores trailing dots and spaces, sigh if len(path.Base(trimmedPath)) <= 12 && strings.Contains(trimmedPath, "~") { return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal short name")) } // both of those could bypass file hiding or possibly leak information even if the file is not hidden } filesToHide := fsrv.transformHidePaths(repl) root := repl.ReplaceAll(fsrv.Root, ".") fsName := repl.ReplaceAll(fsrv.FileSystem, "") fileSystem, ok := fsrv.fsmap.Get(fsName) if !ok { return caddyhttp.Error(http.StatusNotFound, fmt.Errorf("filesystem not found")) } // remove any trailing `/` as it breaks fs.ValidPath() in the stdlib filename := strings.TrimSuffix(caddyhttp.SanitizedPathJoin(root, r.URL.Path), "/") if c := fsrv.logger.Check(zapcore.DebugLevel, "sanitized path join"); c != nil { c.Write( zap.String("site_root", root), zap.String("fs", fsName), zap.String("request_path", r.URL.Path), zap.String("result", filename), ) } // get information about the file info, err := fs.Stat(fileSystem, filename) if err != nil { err = fsrv.mapDirOpenError(fileSystem, err, filename) if errors.Is(err, fs.ErrNotExist) { return fsrv.notFound(w, r, next) } else if errors.Is(err, fs.ErrInvalid) { return caddyhttp.Error(http.StatusBadRequest, err) } else if errors.Is(err, fs.ErrPermission) { return caddyhttp.Error(http.StatusForbidden, err) } return caddyhttp.Error(http.StatusInternalServerError, err) }
// let the standard library do what it does best; note, however, // that errors generated by ServeContent are written immediately // to the response, so we cannot handle them (but errors there // are rare) // // There are a few file modification times that aren't useful // to send in Last-Modified headers, but the golang http library only // omits Last-Modified headers for the Unix epoch time. So, force // the modification time to the epoch time if it's not useful. zeroTime := time.Time{} modTime := info.ModTime() if !usefulModTime(modTime) { modTime = zeroTime } http.ServeContent(w, r, info.Name(), modTime, file.(io.ReadSeeker)) return nil}
modules/caddyhttp/fileserver/staticfiles.go · lines 579–596http.ServeContent — Go's own standard-library helper — writes the file's bytes to the response, handling Range requests, If-Modified-Since and conditional GETs for free. Right after, the function returns nil with no call to next.ServeHTTP. That's the defining trait of a terminal handler: it originates the response itself and ends the chain here, instead of passing control further down the route's Handlers list.Check your trace
Answered in place — nothing is graded, everything is explained. 0 / 3 passed
In Server.ServeHTTP, why is s.tlsApp.HandleHTTPChallenge(w, r) checked before s.serveHTTP(w, r) is ever called?
In routes.go's wrapRoute, why does the closure do `nextCopy := next` instead of just using `next` directly throughout?
What makes file_server a 'terminal' handler rather than ordinary middleware?