The Admin API: Reconfiguring Caddy Without Restarting
Caddy's local admin API listens on localhost:2019 by default and exposes /config/ for GET, POST, PUT, PATCH, and DELETE requests that read or mutate the live configuration tree. Every mutating request funnels through the exact same changeConfig function that caddy.Run/Load calls at boot, so there is no separate 'runtime API' code path — the admin API is just another caller of the mechanism you already learned. This is why Caddy never needs a process restart to pick up a config change: the new config is diffed, provisioned, and swapped into currentCtx in place.
handleConfig: one switch, five HTTP semantics for live config
admin.go · lines 1006–1089GET reads the config at the request path via readConfig, which takes only a read lock (rawCfgMu.RLock) — concurrent GETs never block each other, and reads never contend with request serving. The response body doubles as input to an ETag hash, so a client can later send that value back as If-Match on a write to detect concurrent modification.
POST, PUT, PATCH, and DELETE are all mutations and share this case — but DELETE carries no payload, so it's the one exempted from the Content-Type/body-reading logic that follows.
The request is handed straight to changeConfig — the identical function caddy.Run/Load calls at startup. errSameConfig is the one error swallowed here: a mutation that produces byte-for-byte the same config is treated as a no-op, not a failure.
Any other verb is rejected outright with 405 Method Not Allowed, so /config/ has an exact, closed contract of exactly these five HTTP methods.
func handleConfig(w http.ResponseWriter, r *http.Request) error { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") hash := etagHasher() // Read the config into a buffer instead of writing directly to // the response writer, as we want to set the ETag as the header, // not the trailer. buf := bufferPool.Get().(*bytes.Buffer) buf.Reset() defer bufferPool.Put(buf) configWriter := io.MultiWriter(buf, hash) err := readConfig(r.URL.Path, configWriter) if err != nil { return APIError{HTTPStatus: http.StatusBadRequest, Err: err} } // we could consider setting up a sync.Pool for the summed // hashes to reduce GC pressure. w.Header().Set("Etag", makeEtag(r.URL.Path, hash)) _, err = w.Write(buf.Bytes()) if err != nil { return APIError{HTTPStatus: http.StatusInternalServerError, Err: err} } return nil case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: // DELETE does not use a body, but the others do var body []byte if r.Method != http.MethodDelete { if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "/json") { return APIError{ HTTPStatus: http.StatusBadRequest, Err: fmt.Errorf("unacceptable content-type: %v; 'application/json' required", ct), } } buf := bufPool.Get().(*bytes.Buffer) buf.Reset() defer bufPool.Put(buf) const maxConfigSize = 100 * 1024 * 1024 // 100 MB r.Body = http.MaxBytesReader(w, r.Body, maxConfigSize) _, err := io.Copy(buf, r.Body) if err != nil { return APIError{ HTTPStatus: http.StatusBadRequest, Err: fmt.Errorf("reading request body: %v", err), } } body = buf.Bytes() } forceReload := r.Header.Get("Cache-Control") == "must-revalidate" err := changeConfig(r.Method, r.URL.Path, body, r.Header.Get("If-Match"), forceReload) if err != nil && !errors.Is(err, errSameConfig) { return err } // If this request changed the config, clear the last // config info we have stored, if it is different from // the original source. ClearLastConfigIfDifferent( r.Header.Get("Caddy-Config-Source-File"), r.Header.Get("Caddy-Config-Source-Adapter")) default: return APIError{ HTTPStatus: http.StatusMethodNotAllowed, Err: fmt.Errorf("method %s not allowed", r.Method), } } return nil}
func Load(cfgJSON []byte, forceReload bool) error { if err := notify.Reloading(); err != nil { Log().Error("unable to notify service manager of reloading state", zap.Error(err)) } // after reload, notify system of success or, if // failure, update with status (error message) var err error defer func() { if err != nil { if notifyErr := notify.Error(err, 0); notifyErr != nil { Log().Error("unable to notify to service manager of reload error", zap.Error(notifyErr), zap.String("reload_err", err.Error())) } } if notifyErr := notify.Ready(); notifyErr != nil { Log().Error("unable to notify to service manager of ready state", zap.Error(notifyErr)) } }() err = changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, "", forceReload) if errors.Is(err, errSameConfig) { err = nil // not really an error } return err}
caddy.go · lines 115–142Check your understanding
Answered in place — nothing is graded, everything is explained. 0 / 3 passed
A client sends PATCH /config/apps/http/servers/srv0/listen with a new value. Which function actually performs the mutation-plus-reload, and is also called by caddy.Load at startup?
Why would a PUT to an existing config key fail with 409 Conflict while a POST to that same key succeeds?
What is the admin API's default listener address, and how can it be changed before Caddy even loads a config?