Config API #

Caddy’s ability to adapt to infrastructure changes dynamically is realized to the fullest through the Config API. Unlike conventional server management methods that treat configuration as a rigid static document, Caddy’s Config API lets you manipulate every node in the configuration tree directly and granularly using standard RESTful protocols. By calling HTTP endpoints with various methods like GET, POST, PUT, PATCH, and DELETE, you can add new domains, update backend server (upstream) lists, change security headers, or remove specific services in real time without dropping a single active user connection. We’ll discuss in depth the semantic mapping of HTTP methods, how to manipulate configuration data, integrated deployment automation scripts, multi-tenant orchestration using Node.js, and programmatic error handling methods.


HTTP Methods and Their Semantics in Caddy #

To manage Caddy’s configuration effectively through the Config API, you must understand how Caddy translates standard HTTP methods into data manipulation operations on its in-memory configuration tree. Caddy follows a very consistent REST principle:

+----------------------------------------------------------------------------------+
||                            HTTP Method Semantics in Caddy                       ||
+----------------------------------------------------------------------------------+
||  METHOD | URL PATH                 | INTERNAL OPERATION IN CADDY MEMORY         ||
||---------|--------------------------|--------------------------------------------|
||  GET    | /config/[path]           | Reads node or array data                    ||
||  POST   | /config/[path] (to array)| Appends a new element to an array           ||
||  PUT    | /config/[path]           | Replaces or creates a node                  ||
||  PATCH  | /config/[path] (to obj)  | Merges new properties                       ||
||  DELETE | /config/[path]           | Deletes a node, property, or index          ||
+----------------------------------------------------------------------------------+

The Important POST vs PUT Distinction Rule #

One of the most common mistakes when interacting with the Config API is mixing up the POST and PUT methods:

  • POST: Used specifically to append a new element to a list (array). For example, when you send a POST request to the /config/apps/http/servers/srv0/routes/ endpoint, Caddy inserts that new route object at the very end of the existing routes array.
  • PUT: Used to replace the value of a property or object at a specific path entirely. If that path doesn’t exist yet, PUT creates it. If you send PUT to an array path, the entire old array contents are deleted and replaced with the new data you send.

Reading Configuration (GET) #

The GET method is used to inspect the current active configuration state. You can read the entire configuration document, or target a very specific node using the JSON path structure (JSON Path).

Here are example curl commands for reading Caddy’s configuration, complete with data-slicing visualizations using the jq utility:

# 1. Read Caddy's entire configuration file
curl -s http://localhost:2019/config/ | jq .

# 2. Inspect which applications are currently active
curl -s http://localhost:2019/config/apps/ | jq 'keys'
# Example Output: ["http", "tls"]

# 3. Read the HTTP server configuration in depth
curl -s http://localhost:2019/config/apps/http/servers/ | jq .

# 4. Read the active listen ports on server srv0
curl -s http://localhost:2019/config/apps/http/servers/srv0/listen/ | jq .
# Example Output: [":443", ":80"]

# 5. Read the routing table specifically
curl -s http://localhost:2019/config/apps/http/servers/srv0/routes/ | jq .

This specific configuration reading is very useful in system automation because it saves network bandwidth and makes data processing easier on your controller application side.


Adding New Routes Dynamically (POST) #

The production scenario that most often leverages the Config API is multi-tenant Software-as-a-Service (SaaS) platforms or dynamic web applications that need to register new customer domain names (hosts) to the web server instantly without disturbing other customers’ ongoing traffic.

Using the POST method toward the routes array, you can safely add a new domain entry along with its proxy destination backend:

# Send a new route object using the POST method
curl -s -X POST http://localhost:2019/config/apps/http/servers/srv0/routes/ \
  -H "Content-Type: application/json" \
  -d '{
    "@id": "new-customer-site",
    "match": [
      {
        "host": ["newcustomer.example.com"]
      }
    ],
    "handle": [
      {
        "handler": "subroute",
        "routes": [
          {
            "handle": [
              {
                "handler": "reverse_proxy",
                "upstreams": [
                  {"dial": "localhost:8081"}
                ]
              }
            ]
          }
        ]
      }
    ],
    "terminal": true
  }'

Why Should You Use terminal: true? #

Inside Caddy’s HTTP module, routes are evaluated sequentially from top to bottom. By default, Caddy keeps evaluating the next routes even after finding a match. By setting "terminal": true on the route object above, you instruct Caddy to immediately stop evaluating other routes once the newcustomer.example.com domain matches. This saves server CPU cycles and prevents conflicts with generic route rules below it.

As soon as this HTTP POST request succeeds with a 200 OK status, Caddy instantly activates that route. If a user accesses newcustomer.example.com, Caddy immediately forwards them to port 8081 and automatically initializes SSL/TLS certificate generation for that domain in the background.


Changing Upstreams in Real Time (PUT/PATCH) #

When your backend application does dynamic scaling (auto-scaling) — for example, new containers being added due to load spikes — you need to update the proxy destination server list in Caddy so traffic splits evenly.

1. Changing All Upstreams on a Specific Route (PUT) #

If you know your route’s index position (e.g., the first route at index 0), you can use the PUT method to replace the old upstreams array entirely:

# ANTI-PATTERN: Using shift-prone numeric index paths
curl -s -X PUT \
  "http://localhost:2019/config/apps/http/servers/srv0/routes/0/handle/0/routes/0/handle/0/upstreams" \
  -H "Content-Type: application/json" \
  -d '[
    {"dial": "10.0.1.10:3000"},
    {"dial": "10.0.1.11:3000"},
    {"dial": "10.0.1.12:3000"}
  ]'

2. Changing Upstreams Safely Using a Unique ID (PATCH) #

Using numeric indices like the example above is very prone to failures if another script inserts a new route in front, shifting positions. The best highly recommended solution is leveraging the unique ID addressing (@id) combined with the PATCH method for focused data merging:

# CORRECT: Using a unique ID and PATCH for precision modification
curl -s -X PATCH http://localhost:2019/id/new-customer-site \
  -H "Content-Type: application/json" \
  -d '{
    "handle": [
      {
        "handler": "subroute",
        "routes": [
          {
            "handle": [
              {
                "handler": "reverse_proxy",
                "upstreams": [
                  {"dial": "10.0.1.20:3000"},
                  {"dial": "10.0.1.21:3000"}
                ]
              }
            ]
          }
        ]
      }
    ]
  }'

With the PATCH method to /id/new-customer-site, Caddy only updates the property structure you send inside that ID’d route object, without touching other properties like the already-running domain matching criteria (host matchers).


Deleting Routes Safely (DELETE) #

When a customer’s lease period expires or an application is deactivated, you must remove that route from Caddy’s memory so it doesn’t consume compute resources. You can send a request with the DELETE method to the route’s unique ID endpoint:

# Instantly and safely delete a customer route from memory
curl -s -X DELETE http://localhost:2019/id/new-customer-site

With ID-based addressing, Caddy finds that ID’d object wherever it is in the configuration tree, deletes it instantly, and automatically cleans up the array indices it left behind.


Integrated Deployment Automation Script #

To integrate Caddy into your CI/CD workflow or Docker container orchestration, you can create an automated deployment script. The script below demonstrates how to release a new application version (rolling release) with zero downtime by leveraging container health checks and dynamic Caddy upstream updates:

#!/bin/bash
# zero-downtime-deploy.sh
# Zero-downtime deployment script integrated with the Caddy Config API

set -e

APP_NAME="$1"          # Example: "web-app"
NEW_IMAGE="$2"         # Example: "node-app:v2.0"
DOMAIN="$3"            # Example: "app.example.com"
NEW_PORT="$4"          # Target port for the new container (e.g., 8082)

CADDY_API="http://localhost:2019"
ROUTE_ID="${APP_NAME}-route"

echo "[1/5] Starting the new container creation..."
docker run -d \
  --name "${APP_NAME}-new" \
  -p "${NEW_PORT}:3000" \
  --network app-network \
  "${NEW_IMAGE}"

echo "[2/5] Waiting for the new container to be ready for traffic (Health Check)..."
# Poll the application health endpoint on the new port for up to 60 seconds
for i in {1..30}; do
    STATUS=$(curl -sf "http://localhost:${NEW_PORT}/healthz" \
        -o /dev/null -w "%{http_code}" 2>/dev/null || echo "0")
    if [ "$STATUS" = "200" ]; then
        echo "  ✓ New container is healthy and ready!"
        break
    fi
    if [ "$i" -eq 30 ]; then
        echo "  ✗ ERROR: Health check timeout reached. Canceling the deployment."
        docker rm -f "${APP_NAME}-new"
        exit 1
    fi
    echo "  Waiting for the application to be ready... ($i/30)"
    sleep 2
done

echo "[3/5] Updating the Caddy upstream direction dynamically..."
# We use PATCH to the ID endpoint to switch the target upstream instantly
curl -s -f -X PATCH "${CADDY_API}/id/${ROUTE_ID}" \
  -H "Content-Type: application/json" \
  -d "{
    \"handle\": [{
      \"handler\": \"subroute\",
      \"routes\": [{
        \"handle\": [{
          \"handler\": \"reverse_proxy\",
          \"upstreams\": [{\"dial\": \"localhost:${NEW_PORT}\"}],
          \"health_checks\": {
            \"active\": {
              \"uri\": \"/healthz\",
              \"interval\": \"10s\",
              \"timeout\": \"3s\"
            }
          }
        }]
      }]
    }]
  }"

echo "  ✓ The Caddy upstream target was successfully switched to port ${NEW_PORT}"

echo "[4/5] Draining active connections on the old container (Connection Draining)..."
# Give a pause so requests currently being processed on the old container finish
sleep 10

echo "[5/5] Cleaning up the old container..."
docker rm -f "${APP_NAME}-old" 2>/dev/null || true
docker rename "${APP_NAME}-old" "${APP_NAME}-trash" 2>/dev/null || true
docker rename "${APP_NAME}-new" "${APP_NAME}-old"
docker rm -f "${APP_NAME}-trash" 2>/dev/null || true

echo "✓ Deployment completed with zero downtime!"

In the script above, we proactively mitigate errors. If the new container fails the health check, the script immediately stops and removes the new container without ever touching the running Caddy configuration. This guarantees user traffic stays routed to the stable old container.


Manipulating HTTP Headers Dynamically #

Besides routing upstream traffic, you often need to add or remove HTTP headers on responses dynamically — for example, to enable extra security rules or remove backend framework default headers that leak sensitive information.

Here’s an example of adding a headers handler into a Caddy route dynamically via the API:

# Adding the Strict-Transport-Security header and removing the Server header
curl -s -X POST \
  "http://localhost:2019/config/apps/http/servers/srv0/routes/0/handle/0/routes" \
  -H "Content-Type: application/json" \
  -d '{
    "@id": "global-security-headers",
    "handle": [{
      "handler": "headers",
      "response": {
        "set": {
          "Strict-Transport-Security": ["max-age=31536000; includeSubDomains; preload"],
          "X-Frame-Options": ["SAMEORIGIN"],
          "X-Content-Type-Options": ["nosniff"]
        },
        "delete": ["Server", "X-Powered-By", "X-AspNet-Version"]
      }
    }]
  }'

By inserting this headers handler at the top of the routing chain, Caddy automatically processes this header manipulation before forwarding or returning responses to users.


Multi-Site Orchestration using Node.js #

For SaaS platforms or web-based management systems, you can write a Caddy controller module using a programming language like JavaScript/Node.js.

Here’s a complete controller module example using the axios library to add, update, and delete customer domains programmatically:

// caddy-orchestrator.js
// Caddy Config API orchestration module for SaaS platforms

const axios = require('axios');

const CADDY_API = 'http://localhost:2019';

/**
 * Registers a new tenant domain in Caddy
 * @param {string} tenantId - The customer's unique ID
 * @param {string} domain - The customer's custom domain
 * @param {number} backendPort - The customer application's local port
 */
async function registerTenant(tenantId, domain, backendPort) {
    const routePayload = {
        "@id": `tenant-${tenantId}`,
        "match": [{ "host": [domain] }],
        "handle": [{
            "handler": "subroute",
            "routes": [{
                "handle": [{
                    "handler": "reverse_proxy",
                    "upstreams": [{ "dial": `localhost:${backendPort}` }],
                    "health_checks": {
                        "active": {
                            "uri": "/healthz",
                            "interval": "15s",
                            "timeout": "3s"
                        }
                    }
                }]
            }]
        }],
        "terminal": true
    };

    try {
        const response = await axios.post(
            `${CADDY_API}/config/apps/http/servers/srv0/routes/`,
            routePayload,
            { headers: { 'Content-Type': 'application/json' } }
        );
        if (response.status === 200) {
            console.log(`✓ Tenant [${tenantId}] successfully registered with domain: ${domain}`);
        }
    } catch (error) {
        console.error(`✗ Failed to register tenant [${tenantId}]:`, error.response ? error.response.data : error.message);
    }
}

/**
 * Removes a tenant domain from Caddy
 * @param {string} tenantId - The unique customer ID to remove
 */
async function unregisterTenant(tenantId) {
    try {
        const response = await axios.delete(`${CADDY_API}/id/tenant-${tenantId}`);
        if (response.status === 200) {
            console.log(`✓ Tenant domain [${tenantId}] successfully removed from Caddy memory.`);
        }
    } catch (error) {
        console.error(`✗ Failed to remove tenant [${tenantId}]:`, error.response ? error.response.data : error.message);
    }
}

/**
 * Changes the backend upstream port for an existing tenant
 * @param {string} tenantId - The customer's unique ID
 * @param {number} newPort - The new backend port
 */
async function updateTenantBackend(tenantId, newPort) {
    try {
        // 1. Fetch the tenant's current route configuration data
        const getResponse = await axios.get(`${CADDY_API}/id/tenant-${tenantId}`);
        const currentRoute = getResponse.data;

        // 2. Modify the target dial property in the JSON data structure
        currentRoute.handle[0].routes[0].handle[0].upstreams[0].dial = `localhost:${newPort}`;

        // 3. Send back the updated configuration using PUT to the ID endpoint
        const putResponse = await axios.put(
            `${CADDY_API}/id/tenant-${tenantId}`,
            currentRoute,
            { headers: { 'Content-Type': 'application/json' } }
        );

        if (putResponse.status === 200) {
            console.log(`✓ Backend port for tenant [${tenantId}] successfully switched to: ${newPort}`);
        }
    } catch (error) {
        console.error(`✗ Failed to update backend for tenant [${tenantId}]:`, error.response ? error.response.data : error.message);
    }
}

// ==========================================
// Example Orchestration Execution Simulation:
// ==========================================
async function runDemo() {
    console.log("Starting the orchestration simulation...");
    await registerTenant('user-100', 'client-a.myplatform.com', 8081);
    await registerTenant('user-200', 'client-b.myplatform.com', 8082);
    
    // Simulate a backend server update
    await updateTenantBackend('user-100', 9091);
    
    // Simulate a service removal
    await unregisterTenant('user-200');
}

runDemo();

The Node.js module above illustrates how you can fully integrate Caddy into your application’s backend administration system, eliminating the need to manually touch the server terminal.


Programmatic Error Handling #

When building automation systems with the Config API, you must handle errors defensively. Caddy returns precise HTTP error status codes along with a JSON payload explaining the failure cause:

1. 400 Bad Request Error (Broken JSON / Wrong Schema) #

If the JSON payload you send has syntax typos or properties that don’t match the Caddy module schema specification, Caddy rejects the request and returns error details:

# Sending deliberately broken JSON syntax
curl -i -X POST http://localhost:2019/config/apps/http/servers/srv0/routes/ \
  -H "Content-Type: application/json" \
  -d '{ "match": [ { "host": "app.com" } ] }' # Should be host as an array of strings, not a plain string

Caddy returns an error response like this:

HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Tue, 16 Jun 2026 10:45:00 GMT
Connection: close

{"error":"loading new config: http app: server srv0: route 0: matching host: decoding host value: json: cannot unmarshal string into Go struct field host of type []string"}

2. 404 Not Found Error (Unknown Path) #

If you send a GET/POST/PUT request to a path not registered in the current active configuration tree, Caddy responds with 404:

# Accessing a server node that never existed
curl -i -X GET http://localhost:2019/config/apps/http/servers/fake-server/

Caddy’s response output:

HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"unknown object key: fake-server"}

Status Code Validation Tactics in Automation Scripts #

To ensure your orchestration system doesn’t continue to the next stage if Caddy rejects a configuration update, you must always capture the HTTP response status code from API calls:

# Capturing the HTTP status code from the API response
HTTP_RESPONSE=$(curl -s -w "%{http_code}" -o response.json \
  -X POST http://localhost:2019/config/apps/http/servers/srv0/routes/ \
  -H "Content-Type: application/json" \
  -d @route.json)

if [ "$HTTP_RESPONSE" -ne 200 ]; then
    ERROR_MSG=$(jq -r '.error' response.json)
    echo "✗ ERROR: Caddy API failure (HTTP $HTTP_RESPONSE)!"
    echo "Error Details: $ERROR_MSG"
    rm -f response.json
    exit 1
fi

echo "✓ Caddy configuration updated successfully!"
rm -f response.json

Summary #

  • HTTP Method Semantics: The Config API strictly follows REST principles: GET reads, POST appends to arrays, PUT replaces entirely, PATCH merges properties, and DELETE removes nodes.
  • The @id Advantage: Always use the unique @id property on frequently managed configuration objects to avoid numeric index shifts that trigger script logic failures.
  • SaaS Automation: Use dynamic orchestration with Node.js/Python to automatically register customer domains as soon as they sign up on your platform.
  • Temporary Change Impact: Configuration modifications through the Config API are immediate (in-memory). Make sure your controller application stores that configuration state in an external database for long-term persistence.
  • Strict Error Handling: Always validate the HTTP response code (expecting 200 OK status) and read the "error" string property in the JSON response to diagnose schema validation failures.
  • Fast Route Termination: Set the terminal: true parameter on dynamic routes so Caddy immediately stops searching other routes once it finds a matching customer domain.

← Previous: Admin Endpoint   Next: Reload Config →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact