Caddy Onboarding LabChapter VI — The Admin API: Reconfiguring Caddy Without Restarting0/6Contents
Chapter VI

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.


One Endpoint, Two Origins: Boot-Time and Runtime Config Converge
caddy.Run (CLI boot)caddy.go
Admin API clientadmin.go
handleConfigadmin.go
changeConfigcaddy.go
unsyncedConfigAccessadmin.go
unsyncedDecodeAndRuncaddy.go
currentCtx (live config)caddy.go
Figure I · Click a node to see what it does, where it lives in the code, and its labeled connections — the annotation opens beside it.
walkthrough

handleConfig: one switch, five HTTP semantics for live config

admin.go · lines 10061089
lines 1008–1034

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

lines 1035–1043

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.

lines 1069–1070

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.

lines 1081–1089

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}
step 1 of 4
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 115142
Figure II · This is the same changeConfig handleConfig calls for a PATCH — Load calls it too, with method POST, path "/config", and the entire freshly-adapted JSON as the body. There is no separate 'apply the startup config' code path; booting and a live admin-API edit are the same operation with a different caller.
checkpoint

Check 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?