How Caddy's JSON API for Dynamic Configuration Works: Complete Technical Guide

Caddy's JSON API enables runtime configuration changes through an admin HTTP server that exposes REST endpoints for reading, patching, and reloading the active configuration without process restarts.

The Caddy web server supports dynamic configuration management through a built-in JSON API, allowing operators to modify TLS certificates, routes, and server blocks while the server remains active. This capability centers on the admin server configured via the Admin field in the top-level Config struct, which exposes HTTP endpoints for granular configuration manipulation. According to the caddyserver/caddy source code, the API implements optimistic concurrency control using ETags and atomic configuration swaps to ensure safe, concurrent updates.

Admin Server Architecture and Request Routing

When Caddy starts, the admin server initializes an adminHandler (see admin.go lines 221-236) that registers routes mapping to specific configuration operations. The handler receives all requests via adminHandler.ServeHTTP (lines 787-795) and dispatches them through an internal mux after performing security checks via checkHost and checkOrigin.

The API exposes the following primary routes:

  • /config/* – Managed by handleConfig for read and write operations on configuration subtrees
  • /load – Handled by adminLoad.handleLoad for full configuration replacement
  • /id/* – Resolved by handleConfigID for object lookup using the @id meta-field
  • /stop – Processed by handleStop for graceful server termination

Read requests execute under a read lock, while mutations acquire a write lock through rawCfgMu to protect the internal rawCfg map (see the concurrency comments at admin.go lines 45-47).

Reading Configuration with ETag Support

The GET /config/... endpoint triggers handleConfig (lines 1011-1032), which retrieves JSON subtrees through readConfig (lines 267-273 in caddy.go). This function wraps unsyncedConfigAccess to traverse the raw configuration structure and return the requested data.

For optimistic concurrency control, the handler generates an ETag header using makeEtag (lines 997-1002). The ETag value follows the format "path <hex-hash>", where the hash represents the current state of the requested subtree. Clients must include this value in subsequent If-Match headers when modifying configuration to prevent lost updates.

curl -s http://localhost:2019/config/ | jq .

The response includes an Etag header (e.g., Etag: "config 7b8e...f0") that represents the configuration's current hash state.

Mutating Configuration Dynamically

Configuration changes occur through POST, PUT, PATCH, and DELETE requests to /config/*, which delegate to changeConfig (lines 145-164 in caddy.go). This function implements a multi-stage workflow for safe configuration updates.

The changeConfig Workflow

The mutation process follows these steps:

  1. If-Match Validation – Parses the quoted header and computes the current hash via unsyncedConfigAccess, rejecting requests with HTTP 412 if the hash differs (lines 172-203)
  2. Apply Change – Calls unsyncedConfigAccess with the HTTP method, JSON body, and target path to traverse rawCfg and perform the operation, including array handling and ellipsis support (lines 441-495 and 511-595 in admin.go)
  3. Detect No-Op – Compares the marshalled representation (newCfg) against the cached copy (rawCfgJSON) and returns errSameConfig if unchanged and forceReload is false (lines 220-225)
  4. Re-index @id Fields – Rebuilds the lookup table via indexConfigObjects (lines 274-282) to maintain the /id/* resolution capability
  5. Load New Config – Invokes unsyncedDecodeAndRun (lines 312-324) to strip meta-fields, unmarshal into *Config, and re-initialize plugins and listeners atomically

If any step fails, Caddy executes rollback logic (lines 336-353) to restore the previous configuration, ensuring the running server remains stable.

Array Manipulation with Ellipsis Syntax

To append elements to arrays without overwriting existing items, use the trailing ... path syntax:

curl -X POST \
     -H "Content-Type: application/json" \
     -d '[{"handle":[{"handler":"static_response","body":"new"}]}]' \
     http://localhost:2019/config/apps/http/servers/srv0/routes/...

The trailing ellipsis instructs Caddy to treat the payload as an array and append each element to the existing routes rather than replacing the entire array.

Full Configuration Replacement

The /load endpoint provides atomic full-configuration replacement through handleLoad in the adminLoad module. This endpoint reads the request body, optionally validates an If-Match header, and calls caddy.Load (lines 68-78 in caddyconfig/load.go). This function clears the previous configuration and runs the new one exactly like a fresh caddy run command, but without restarting the process.

cat new-config.json | curl -X POST \
    -H "Content-Type: application/json" \
    http://localhost:2019/load

If the new configuration fails to load, Caddy retains the previous active configuration, preventing service disruption.

Object Resolution Using @id

When configuration objects include the @id meta-field, indexConfigObjects (lines 274-300 in caddy.go) maintains a mapping from ID values to their JSON pointer paths. The GET /id/<id> endpoint uses this index to retrieve objects without requiring knowledge of their full path structure.

curl http://localhost:2019/id/myroute

This resolves the object tagged with "@id":"myroute" regardless of its nested location within the configuration tree.

Practical API Usage Examples

Optimistic Concurrency with ETags

To safely update a configuration field while preventing conflicts from concurrent modifications:


# Capture the current ETag

etag=$(curl -i -s http://localhost:2019/config/ | awk -F'"' '/Etag/ {print $2}')

# Submit patch with validation

curl -X PATCH \
     -H "Content-Type: application/json" \
     -H "If-Match: \"$etag\"" \
     -d '{"listen":"0.0.0.0:2020"}' \
     http://localhost:2019/config/admin

If the configuration changed between the GET and PATCH requests, Caddy returns HTTP 412 Precondition Failed, prompting the client to retry with the current state.

Deleting Configuration Subtrees

Remove specific configuration paths using DELETE requests:

curl -X DELETE http://localhost:2019/config/apps/http/servers/srv0/routes/0

This removes the first route from the specified server's routes array.

Summary

  • The admin server initializes at startup via AdminConfig, creating an adminHandler that routes requests to specific handlers like handleConfig and handleLoad
  • ETag-based optimistic concurrency prevents lost updates by requiring matching hashes in If-Match headers for all mutation operations
  • The changeConfig function in caddy.go orchestrates safe configuration updates through validation, application, re-indexing, and atomic loading stages
  • Automatic rollback occurs if configuration loading fails, ensuring the server never enters an undefined state
  • The @id meta-field system enables stable object references via the /id/* endpoint, decoupling clients from specific JSON paths

Frequently Asked Questions

What port does Caddy's JSON API use by default?

By default, the admin server listens on localhost:2019. You can modify this through the admin section of your Caddy configuration or via the CADDY_ADMIN environment variable. The listen field accepts network addresses in the format host:port or Unix socket paths.

How does Caddy handle concurrent configuration updates from multiple clients?

Caddy implements optimistic concurrency control using ETag headers. When reading configuration, the server returns an ETag representing the current state hash. Clients must include this value in the If-Match header when submitting changes. If the configuration changed between read and write (hash mismatch), Caddy returns HTTP 412 Precondition Failed, forcing the client to fetch the current state and retry.

What happens if a dynamic configuration update fails halfway through?

Caddy maintains the previous configuration in memory during the update process. If unsyncedDecodeAndRun fails to load the new configuration at caddy.go lines 336-353, the server automatically rolls back to the previous stable configuration. This ensures that partial or invalid configurations never disrupt active services.

Can I use the JSON API to update TLS certificates dynamically?

Yes. You can POST new certificate data to /config/apps/tls/certificates or use the /id/* endpoint if your certificates have @id tags. Caddy will reload the TLS configuration without dropping existing connections, enabling zero-downtime certificate rotation through the API.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →