Caddy Onboarding LabChapter I — End to end: a client sends an HTTP request to a running Caddy server0/6Contents
Chapter I

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.


walkthrough

App.Start: wiring a configured Server to a real net/http.Server

modules/caddyhttp/app.go · lines 472492
lines 472–473

caddyhttp.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.

lines 474–478

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.

line 479

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.

lines 482–490

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			},		}
step 1 of 4
walkthrough

Server.ServeHTTP: the first Caddy code every request touches

modules/caddyhttp/server.go · lines 454501
lines 454–456

caddy.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.

lines 462–470

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.

lines 471–492

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.

lines 494–498

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.

lines 500–501

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)
step 1 of 5
walkthrough

routes.go: a matcher set qualifies a Route, then its Handlers chain like middleware

modules/caddyhttp/routes.go · lines 256326
lines 256–258

wrapRoute 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.

lines 269–272

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.

lines 278–282

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.

lines 299–306

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.

lines 308–311

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.

line 323

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)		})	}}
step 1 of 6
walkthrough

file_server: resolving the request path against the filesystem

modules/caddyhttp/fileserver/staticfiles.go · lines 269319
lines 269–270

file_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.

lines 287–288

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.

line 296

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.

lines 308–319

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)	}
step 1 of 4
	// 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 579596
Figure I · http.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.
The path of one request, start to finish
Clientmodules/caddyhttp/server.go
net/http.Servermodules/caddyhttp/app.go
Server.ServeHTTPmodules/caddyhttp/server.go
primaryHandlerChainmodules/caddyhttp/app.go
Route (wrapRoute)modules/caddyhttp/routes.go
file_server (FileServer.ServeHTTP)…ddyhttp/fileserver/staticfiles.go
Figure II · Click a node to see what it does, where it lives in the code, and its labeled connections — the annotation opens beside it.
checkpoint

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?