caddy.Load: Decoding JSON into a Running Config
This module traces caddy.Load from the moment it receives Caddyfile-adapted JSON to the moment a fully running Config is live: it decodes the JSON, builds a Context to provision every app module named under "apps" (including the http app), starts them all, and only then atomically swaps the result in for the previous config, which is cleanly stopped afterward. It also shows that changeConfig — Load's underlying engine — treats the live config as a generic JSON tree that can be mutated at any path with HTTP-verb semantics (GET/POST/PUT/PATCH/DELETE), the same mechanism the admin API exposes directly.
caddy.Load: the public entry point
caddy.go · lines 115–142cfgJSON here is exactly the byte slice you saw produced by adapting a Caddyfile in the earlier module — Load doesn't care where it came from. Before touching anything, it tells the OS service manager (e.g. systemd) a reload is starting.
A deferred block reports success or failure to the service manager once the reload attempt finishes, regardless of which path below it takes — it inspects the same err variable the rest of the function sets.
The actual work is one call: changeConfig(http.MethodPost, "/"+rawConfigKey, cfgJSON, ...). rawConfigKey is literally the string "config", so Load is really just POSTing the whole JSON document to path "/config" — the identical low-level entry point the admin API's config endpoints use. If the decoded config turns out to be byte-identical to what's already running and forceReload is false, changeConfig returns the errSameConfig sentinel, which Load quietly turns into a nil error.
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}
unsyncedDecodeAndRun: decode, provision, swap, cleanup
caddy.go · lines 337–376Decode: RemoveMetaFields strips admin-API-only bookkeeping like "@id" tags out of the JSON (they'd otherwise fail to unmarshal into any struct field), then StrictUnmarshalJSON decodes the cleaned bytes into a brand-new *Config — the same struct whose AppsRaw field you'll see used next.
A guard rejects a dynamically pulled config that itself tries to pull another config without a positive load_delay — a safety net against infinite reload loops, not part of the main decode-provision-swap path.
run(newCfg, true) is where provisioning and starting actually happen: it builds a fresh Context around newCfg (the same Context mechanism from the plugin-system module), provisions every configured app module — including instantiating the http app — and starts each one. Only if every app starts successfully does run return without error.
Only once the entire new config is already fully running does the swap happen: lock currentCtxMu, stash the old currentCtx as oldCtx, install the new ctx as currentCtx, unlock. Any code calling ActiveContext() sees either the whole old config or the whole new one, never a half-built mix — this is the 'atomic replacement' the module title promises.
Only after the swap is complete does unsyncedStop(oldCtx) run, stopping every app in the previous config and canceling its Context (which triggers Cleanup() on every module that Context provisioned — the same Cleanup hook from the plugin-system module). Starting the new config fully before stopping the old one means there is never a moment where nothing is serving traffic.
func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error { // remove any @id fields from the JSON, which would cause // loading to break since the field wouldn't be recognized strippedCfgJSON := RemoveMetaFields(cfgJSON) var newCfg *Config err := StrictUnmarshalJSON(strippedCfgJSON, &newCfg) if err != nil { return err } // prevent recursive config loads; that is a user error, and // although frequent config loads should be safe, we cannot // guarantee that in the presence of third party plugins, nor // do we want this error to go unnoticed (we assume it was a // pulled config if we're not allowed to persist it) if !allowPersist && newCfg != nil && newCfg.Admin != nil && newCfg.Admin.Config != nil && newCfg.Admin.Config.LoadRaw != nil && newCfg.Admin.Config.LoadDelay <= 0 { return fmt.Errorf("recursive config loading detected: pulled configs cannot pull other configs without positive load_delay") } // run the new config and start all its apps ctx, err := run(newCfg, true) if err != nil { return err } // swap old context (including its config) with the new one currentCtxMu.Lock() oldCtx := currentCtx currentCtx = ctx currentCtxMu.Unlock() // Stop, Cleanup each old app unsyncedStop(oldCtx)
Context.App: lookup, decode, cache
context.go · lines 505–522If this app name already failed to provision earlier in the same config load, App returns the cached error immediately instead of retrying a doomed provision.
If the app was already provisioned earlier in this same call (cfg.apps is keyed by name, e.g. "http"), App just returns the existing instance — the call is idempotent per Context, which matters because other apps' submodules may ask for "http" too.
Otherwise, appRaw is the json.RawMessage sitting under config.apps.http (or whichever name), and ctx.LoadModuleByID(name, appRaw) is the exact decode/Provision/Validate mechanism you saw in the plugin-system module — for "http" this is the call that produces the caddyhttp.App instance.
Once decoded, the raw JSON for that app is discarded so the garbage collector can free it — Caddy never needs to re-read that JSON once the live module object exists.
func (ctx Context) App(name string) (any, error) { // if the app failed to load before, return the cached error if err, ok := ctx.cfg.failedApps[name]; ok { return nil, fmt.Errorf("loading %s app module: %v", name, err) } if app, ok := ctx.cfg.apps[name]; ok { return app, nil } appRaw := ctx.cfg.AppsRaw[name] modVal, err := ctx.LoadModuleByID(name, appRaw) if err != nil { return nil, fmt.Errorf("loading %s app module: %v", name, err) } if appRaw != nil { ctx.cfg.AppsRaw[name] = nil // allow GC to deallocate } return modVal, nil}
Check your understanding
Answered in place — nothing is graded, everything is explained. 0 / 3 passed
If Load is called with JSON that is byte-identical to the currently-running config, and forceReload is false, what happens?
Inside provisionContext, which call is directly responsible for causing the "http" app's Provision method to run for the first time during a Load?
Why does unsyncedDecodeAndRun call run(newCfg, true) — which provisions and starts the new apps — before it calls unsyncedStop on the old context?