Caddy Architecture #

Understanding Caddy’s internal architecture isn’t just a theoretical need. When you face a tricky configuration problem in production, design advanced microservices architectures, or debug a failing TLS handshake, knowing how Caddy works behind the scenes is what separates guessing at solutions from solving them precisely. Caddy is designed with a very modern architectural philosophy: every component is a composable module, and server configuration is treated as living structured data that can be manipulated at any time without disturbing active connections.


Overall Architecture Overview #

The Caddy core is designed as a minimalist runtime engine. The web server functionality you use every day is actually implemented through independent modules that connect to each other dynamically.

The relationships between the main components inside a Caddy process are shown in the following diagram:

flowchart TD
      subgraph Process ["Caddy Process Space"]
          direction TB
          Admin["Admin REST API (Port 2019)"]
          HTTPApp["HTTP App Module (Port 80/443)"]
          Registry["Caddy Module Registry"]
          TLSApp["TLS App (Certificate Manager)"]
          
          Admin --> Registry
          HTTPApp --> Registry
          TLSApp --> Registry
      end
      
      subgraph Modules ["Handler & Provider Modules"]
          direction LR
          Handlers["http.handlers.*"]
          Issuance["tls.issuance.*"]
          Storage["tls.storage.*"]
      end
      
      Registry --> Handlers
      Registry --> Issuance
      Registry --> Storage
      
      style Process stroke:#0288d1,stroke-width:2px
      style Modules stroke:#43a047,stroke-width:2px

Every part of Caddy communicates through standardized Go interfaces. Critical components like the HTTP server (http), the TLS module (tls), and the Admin API (admin) are all registered as modules in Caddy’s global registry when the server starts.


The Module System, Namespaces, and Lifecycle Hooks #

Caddy v2’s modular structure is built on a Go module system that registers dynamically when the program initializes (init()). Each module has a unique ID in a hierarchical namespace format using dots as separators.

Caddy’s module namespaces are grouped by role category:

caddy/
  ├── apps/
  │   ├── http                          (Main module for the HTTP/HTTPS server)
  │   └── tls                           (Main module for TLS management)
  ├── http.handlers/
  │   ├── static_response               (Serves static HTTP responses directly)
  │   ├── reverse_proxy                 (Forwards requests to upstream backends)
  │   ├── file_server                   (Serves physical files from disk)
  │   ├── encode                        (Performs Brotli/Gzip compression)
  │   ├── rewrite                       (Rewrites internal request URIs)
  │   └── basicauth                     (Basic HTTP authentication protection)
  ├── http.matchers/
  │   ├── host                          (Matches request domains)
  │   ├── path                          (Matches request URL paths)
  │   └── method                        (Matches HTTP methods like GET/POST)
  ├── tls.issuance/
  │   ├── acme                          (Negotiates Let's Encrypt / ZeroSSL)
  │   └── internal                      (Internal CA issuance for localhost)
  └── tls.storage/
      └── file                          (Stores SSL certificates on local disk)

Module Assembly Lifecycle (Lifecycle Hooks) #

When Caddy loads a new configuration, the declared modules aren’t run immediately. Caddy forces every module through strict lifecycle hooks using Go’s type reflection to guarantee that all module dependencies are populated and safe to use:

  1. Instantiation (Unmarshal): Caddy reads the JSON configuration structure and creates the corresponding module memory objects using a JSON parser.
  2. Provisioning (Provision Hook): If the module implements the caddy.Provisioner interface, Caddy triggers the Provision() function. Here, the module can initialize internal dependencies (like opening a local database connection or compiling regular expressions) and grab references to other Caddy modules.
  3. Validation (Validator Hook): If the module implements caddy.Validator, the Validate() function is called. Here, the module must check whether the given configuration is correct and safe (for example, ensuring the target port is valid or the file path exists). If validation fails, the entire configuration change process is aborted automatically.
  4. Activation (Start Hook): After all modules pass validation, they start actively serving requests or running background tasks.
  5. Destruction (Cleanup Hook): Replaced old modules get their Cleanup() function called to release resources cleanly (like closing socket ports or file connections).

The HTTP Request Processing Pipeline #

Every HTTP request received by Caddy goes through a very strictly defined processing pipeline. Understanding this request flow makes it easier to diagnose which layer a request is failing at.

The request processing flow from client to response is shown in the following diagram:

flowchart TD
      Client["Client (Browser)"] -->|"TCP Connection"| Listener["TLS Listener (SNI Decryption)"]
      Listener -->|"Decrypted Request"| Server["HTTP Server Module"]
      Server -->|"Match Routes"| Routing["Route Matching Engine"]
      Routing -->|"Evaluate Matchers"| Matchers["HTTP Matchers (AND Logic)"]
      
      subgraph Chain ["Handler Chain (Pipeline)"]
          direction TB
          H1["Handler 1: encode (gzip/zstd)"]
          H2["Handler 2: headers"]
          H3["Handler 3: basicauth"]
          H4["Handler 4: reverse_proxy / file_server"]
          
          H1 --> H2
          H2 --> H3
          H3 --> H4
      end
      
      Matchers -->|"All Match"| H1
      H4 -. "Return Response" .-> Client

      style Chain stroke:#43a047,stroke-width:2px

The request is decrypted by the TLS Listener using the certificate matched by Server Name Indication (SNI), then parsed by the HTTP server. The routing engine then evaluates all configured routes in sequence using HTTP Matchers.

The Middleware Chain Mechanism in Caddy #

Inside Caddy, the processing chain (Handler Chain) is built using Go’s efficient middleware pattern. Each handler is represented by the caddyhttp.MiddlewareHandler interface, which wraps request processing:

// Logical representation of the handler interface in Caddy
type MiddlewareHandler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request, next Handler) error
}

The request is delivered to the first handler. The first handler can process the request, then call next.ServeHTTP() to forward it to the next handler in the chain. When the last handler (for example, reverse_proxy or file_server) produces a response, that response flows back in reverse through the middleware chain. This allows handlers like encode (compression) to modify the response before it’s actually sent out to the TCP network socket.


Internal Directive Execution Order #

One crucial detail that often confuses users migrating from Nginx or Apache: Caddy executes handler directives by their internal logical priority order, not by the order the lines are written in the Caddyfile.

By default, the Caddyfile compiler reorders the directives you write into the following internal execution order (from highest to lowest priority):

  1. tracing (Distributed request tracing)
  2. map (Dynamic variable mapping)
  3. root (Setting the document root directory)
  4. vars (Internal variable declarations)
  5. rewrite (Internal URL rewriting)
  6. uri (Additional URI manipulation)
  7. try_files (Checking for physical file existence on disk)
  8. basicauth (Basic HTTP authentication protection)
  9. forward_auth (Delegating authentication to an external service)
  10. request_header (Modifying request headers before sending to the backend)
  11. rate_limit (Request rate limiting)
  12. encode (Gzip/Brotli response compression)
  13. header (Modifying response headers before sending to the client)
  14. respond (Serving direct text responses to the client)
  15. reverse_proxy (Forwarding requests to internal backends)
  16. file_server (Serving static files from disk)
  17. templates (Rendering dynamic HTML pages)

Note the following writing comparison example:

# ANTI-PATTERN: The developer tries to put file_server above basicauth so
# static files are served first without regard for authentication.
example.com {
    file_server
    basicauth /protected/* {
        Bob JDJhJDE0JFJT...
    }
}

# CORRECT: The writing order above will not affect execution.
# Caddy automatically executes basicauth (order 8) first
# before file_server (order 16) is called.

If you genuinely need to change this default execution order for very specific custom needs, you can use the route directive block to force Caddy to execute commands linearly in your written order.


Certificate Manager: SSL Lifecycle Automation #

Caddy’s Certificate Manager runs asynchronously in the background of the web server process, managing the acquisition, storage, and renewal of TLS certificates automatically.

The SSL certificate decision and negotiation flow is shown in the following diagram:

flowchart TD
      Start["Domain Detected in Config"] --> Check["Check Local Storage"]
      Check -- "Exists & Valid" --> Use["Use Certificate"]
      Check -- "Missing / Expired" --> Policy["Select Issuer Policy"]
      
      subgraph Issuers ["Issuer Policy Selection"]
          direction TB
          I1["Let's Encrypt (Default)"]
          I2["ZeroSSL (Fallback)"]
          I3["Internal CA (Local/IP)"]
      end
      
      Policy --> Issuers
      Issuers --> Challenge["Determine ACME Challenge"]
      
      subgraph Challenges ["ACME Challenge Types"]
          direction TB
          C1["HTTP-01 (Port 80)"]
          C2["TLS-ALPN-01 (Port 443)"]
          C3["DNS-01 (TXT via API)"]
      end
      
      Challenge --> Challenges
      Challenges --> Request["Request New Certificate"]
      Request --> Save["Save to Disk & Memory"]
      Save --> Active["HTTPS Active & Ready"]
      
      style Issuers stroke:#ffb300,stroke-width:2px
      style Challenges stroke:#ffb300,stroke-width:2px

Caddy checks the local storage folder (defaulting to $HOME/.local/share/caddy). If the certificate isn’t found or has less than 30 days left, Caddy contacts Let’s Encrypt. If Let’s Encrypt hits its request rate limit or suffers network issues, Caddy automatically falls back to ZeroSSL to request an alternative certificate.

Distributed Locking Mechanism #

If you run multiple Caddy instances in a cluster (for example, behind an external load balancer) sharing the same certificate storage (like AWS S3 or a shared Redis database), you must prevent a scenario where every node simultaneously requests SSL certificates for the same domain from Let’s Encrypt. That would trigger the CA’s rate limits very quickly.

Caddy solves this with the Storage Locking module. Before sending a new ACME request, a Caddy node tries to create a lock (lock file or distributed lock key) on the shared storage medium. Other nodes that see the lock exist defer their requests and wait asynchronously until the first node finishes issuing the SSL certificate, then load the successfully stored certificate directly into their local memory.


On-Demand TLS for Enterprise Scale #

One of the biggest advantages of Caddy’s Certificate Manager is support for On-Demand TLS. On traditional web servers, all domain names must be declared in the configuration file before the server runs.

With On-Demand TLS, Caddy can dynamically issue SSL certificates at the moment a TLS handshake first happens from a new visitor, without any restart or config reload.

{
    # Global On-Demand TLS option configuration
    on_demand_tls {
        # Internal API endpoint responsible for validating domains
        ask https://api.ourinfrastructure.com/v1/validate-domain
        
        # Limit certificate issuance to prevent abuse
        interval 1m
        burst 10
    }
}

# Catch all port 443 traffic for dynamic custom domains
:443 {
    tls {
        on_demand
    }
    
    reverse_proxy localhost:8000
}

Denial of Service (DoS) Attack Prevention #

If not secured, the On-Demand TLS feature can be exploited by attackers to cripple your server. An attacker simply sends thousands of HTTPS requests using random domain names pointed at your server’s IP. If Caddy processes all of them, your storage disk fills up with junk certificates and your server gets blocked by Let’s Encrypt for violating request quotas.

Caddy mitigates this DoS attack with two safety parameters:

  1. The ask Endpoint: Caddy sends an HTTP GET request to your internal endpoint before processing certificate issuance. If the domain isn’t registered in your database, your API must return a status other than 200 OK, and the TLS handshake is abruptly terminated by Caddy without triggering a request to Let’s Encrypt.
  2. Rate Limiting (Interval & Burst): Limits the rate of new SSL certificate creation within a time window so the server CPU isn’t overloaded by TLS handshake encryption key calculations.

The Admin API and Native JSON Configuration #

Although most developers are more familiar with Caddyfile syntax, Caddy doesn’t actually recognize this config file format directly. The Caddyfile is just a human-friendly representation that gets adapted into a structured JSON document when the server starts.

That structured JSON format is what gets sent to Caddy’s REST Admin API on port 2019 to modify the in-memory configuration dynamically:

# Example: Fetch a copy of the active configuration in native JSON format
curl http://localhost:2019/config/ | jq .

Dynamic Configuration Transaction Mechanism (Atomic Rollback) #

When you send a new JSON configuration to Caddy’s Admin API (for example, via POST to /load or partial modification through JSON Pointer), Caddy processes the change in a very safe atomic transaction:

  1. Validation Stage: Caddy parses the incoming JSON payload and initializes all affected new modules. Caddy triggers the Provision() and Validate() functions on each new module to ensure there are no logic configuration errors or broken dependencies.
  2. Transaction Rollback (Abort): If even a single new module returns an error during validation or assembly, the transaction is aborted immediately. All partially created new modules are cleaned up, and Caddy continues serving traffic with the old running configuration modules. No downtime and no half-configured server state.
  3. Atomic Swap: If all new modules pass validation, Caddy performs an instant internal memory pointer swap (hot-swap). New request traffic is routed to the new modules, while active connections on the old modules are allowed to finish processing their requests naturally before the old modules shut down cleanly.

Summary #

  • Module-Based Architecture — The Caddy core acts as a minimalist runtime, while all web server features attach to it as modules with structured namespaces.
  • Ordered Request Pipeline — HTTP request flow passes through the TLS Listener, HTTP Server, Route Matchers, and Handler Chain predictably.
  • Internal Directive Priority — Directive execution order is determined by Caddy’s internal priority logic, not by the order you write them in the Caddyfile.
  • Background Certificate Manager — Manages the SSL lifecycle (Let’s Encrypt / ZeroSSL) in the background, with automatic renewal 30 days before expiry.
  • Dynamic On-Demand TLS — Can issue new SSL certificates in real time when a first TLS handshake arrives from an unregistered domain.
  • API-First & JSON Native — Internal configuration is structured JSON that can be accessed and changed instantly via the REST API on port 2019 without downtime.

← Previous: Caddy vs Apache   Next: Installation →

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