Caddy doesn’t just log; it logs smartly, and structured JSON is its secret weapon for making those logs actionable.

Let’s see it in action. Imagine a Caddy server handling requests.

{
  "level": "info",
  "ts": "2023-10-27T10:00:00.123Z",
  "logger": "http.log",
  "msg": "request received",
  "request": {
    "method": "GET",
    "remote_addr": "192.168.1.100:54321",
    "user_agent": "curl/7.68.0",
    "headers": {
      "User-Agent": ["curl/7.68.0"]
    },
    "uri": "/hello",
    "proto": "HTTP/1.1"
  },
  "duration": "10.5ms",
  "status": 200,
  "bytes_sent": 12,
  "bytes_received": 0,
  "server_ip": "10.0.0.5",
  "server_port": 8080,
  "tls": {
    "version": 0,
    "cipher_suite": 0,
    "server_name": "",
    "proto": ""
  }
}

This isn’t just a wall of text; it’s a machine-readable event. Each field tells a specific story about the request: who made it (remote_addr), what they asked for (request.uri), how long it took (duration), and the outcome (status). This structured format is what allows tools like Splunk, Elasticsearch, or even simple jq commands to filter, aggregate, and analyze your web traffic with incredible precision.

The problem Caddy’s structured JSON logging solves is the ambiguity and difficulty of parsing traditional log formats. If you’ve ever tried to extract specific metrics from plain text logs, you know the pain of brittle regular expressions and the constant battle against log rotation and format changes. JSON logging eliminates this by providing a consistent, predictable schema for every log entry.

Internally, Caddy uses a robust logging library that, when configured for JSON, serializes log events into JSON objects before writing them to the configured output (stdout, stderr, or a file). Each log event is a Go struct that gets marshaled into JSON. The http.log logger, for example, is responsible for generating the request-specific fields you see above, including timing, status codes, and request details.

To enable this, you’ll primarily interact with the log directive in your Caddyfile. The key is to specify json as the format.

Here’s a basic Caddyfile configuration:

:8080 {
    log {
        output stdout
        format json
    }
    respond /hello "Hello, world!"
}

In this configuration:

  • log {}: This block enables logging.
  • output stdout: This directs the logs to standard output, which is common for containerized environments or simple local testing. You could also use output /var/log/caddy/access.log.
  • format json: This is the crucial part that tells Caddy to emit logs in JSON format.

You can also customize the fields that are logged. For instance, if you want to include the request ID (useful for tracing distributed systems) and the upstream response duration, you’d modify the log directive:

:8080 {
    log {
        output stdout
        format json {
            # Include request ID if available (from a reverse_proxy upstream)
            # and the upstream_duration field.
            fields {
                request_id
                upstream_duration
            }
        }
    }
    reverse_proxy localhost:8081
}

When a request hits this Caddyfile, the JSON log might look like this:

{
  "level": "info",
  "ts": "2023-10-27T10:05:00.456Z",
  "logger": "http.log",
  "msg": "request received",
  "request": {
    "method": "GET",
    "remote_addr": "192.168.1.101:12345",
    "user_agent": "curl/7.68.0",
    "headers": {
      "User-Agent": ["curl/7.68.0"]
    },
    "uri": "/",
    "proto": "HTTP/1.1"
  },
  "duration": "55.2ms",
  "status": 200,
  "bytes_sent": 100,
  "bytes_received": 0,
  "server_ip": "10.0.0.5",
  "server_port": 8080,
  "tls": {},
  "request_id": "abcdef1234567890",
  "upstream_duration": "40.1ms"
}

Notice the request_id and upstream_duration fields that were added because we specified them. The request_id is automatically generated by Caddy for each incoming request, and upstream_duration shows how long the request took to process after Caddy forwarded it to the reverse_proxy upstream.

The most surprising thing about Caddy’s structured logging is how seamlessly it integrates with existing JSON processing tools. You don’t need a special Caddy parser; jq can filter requests by status or request.method directly from the Caddy output stream:

# Find all 404 errors
caddy run | jq 'select(.status == 404)'

# Find requests from a specific IP address
caddy run | jq 'select(.request.remote_addr | startswith("192.168.1.100"))'

This power comes from the fact that Caddy’s JSON logs are not just structured but also contain a wealth of context that is often lost in traditional log formats. The tls field, even when empty, is present, maintaining schema consistency. The logger field tells you which internal Caddy component generated the log message, allowing for more granular debugging.

The next step after mastering structured JSON logging is often exploring Caddy’s structured error logging, which provides similar benefits for diagnostic messages.

Want structured learning?

Take the full Caddy course →