Caddy already logs JSON. Most of the work in a logging guide is making the web server emit something a machine can read, and Caddy skips that step entirely.
What Caddy does not do by default is log requests at all. Access logging is off until you turn it on. So the job here is three lines of Caddyfile, then getting the result off the box.
Add a log block to your site:
example.com {
log
root * /srv/www
file_server
}
That is the whole directive. Caddy writes one JSON object per request to stderr, which is where its other logs already go.
Reload and make a request:
sudo systemctl reload caddy
curl -s https://example.com/pricing > /dev/null
journalctl -u caddy -n 1 -o cat
You get something like this:
{"level":"info","ts":1754582400.123,"logger":"http.log.access.log0","msg":"handled request","request":{"remote_ip":"203.0.113.9","proto":"HTTP/2.0","method":"GET","host":"example.com","uri":"/pricing","headers":{"User-Agent":["Mozilla/5.0"]}},"duration":0.0412,"size":18422,"status":200}
Read the field names before you write queries against them. duration is seconds as a float. size is the response body in bytes. The client address is request.remote_ip, not remote_addr.
Caddy redacts Cookie, Set-Cookie and Authorization headers in access logs. The global log_credentials option turns that off.
Leave it off. Session cookies in a log server are session cookies you now have to protect, and no query you were planning needs them.
An uptime check every 30 seconds is 2,880 log lines a day that tell you nothing:
example.com {
log
log_skip /health
root * /srv/www
file_server
}
Two paths. They produce different query syntax, so pick deliberately.
Caddy installed from the official package runs under systemd and writes to stderr. Systemd puts stderr in the journal. The CL Agent already ships the journal upstream.
If the agent is running, you are done. Nothing else to configure.
There is one consequence. The agent sends journalctl -o json output, so each row is a journal entry and Caddy’s JSON sits inside the MESSAGE field as a string. Reaching a Caddy field takes two hops:
json_extract(json_extract(msg, '$.MESSAGE'), '$.status')
That works fine. It is just wordier, and it mixes access logs in with sshd and the kernel.
Give access logs their own log source and the queries get shorter. Point Caddy at a file:
example.com {
log {
output file /var/log/caddy/access.log {
roll_size 50MiB
roll_keep 5
}
format json
}
root * /srv/www
file_server
}
Then ship the file with an offset script so you never send the same line twice:
#!/bin/bash
# /usr/local/bin/ship-caddy-logs.sh
set -euo pipefail
LOG=/var/log/caddy/access.log
OFFSET_FILE=/var/lib/cl-shipper/caddy.offset
ENDPOINT="https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"
mkdir -p "$(dirname "$OFFSET_FILE")"
offset=$(cat "$OFFSET_FILE" 2>/dev/null || echo 0)
size=$(stat -c %s "$LOG")
# Caddy rolled the file out from under us; start over.
if [ "$size" -lt "$offset" ]; then
offset=0
fi
if [ "$size" -eq "$offset" ]; then
exit 0
fi
tail -c "+$((offset + 1))" "$LOG" \
| gzip \
| curl -sf -X POST "$ENDPOINT" --data-binary @-
echo "$size" > "$OFFSET_FILE"
Run it every minute:
* * * * * /usr/local/bin/ship-caddy-logs.sh
The rotation check matters. roll_size 50MiB means Caddy will rename the file under you, and without that check the shipper goes quiet until someone notices.
Gzip cuts access logs by roughly ten to one. The ingest endpoint detects gzip from the body itself, so no header is needed.
Keep Option A running as well. You still want Caddy’s TLS and startup errors, and those are not in the access log.
The examples below assume Option B, where msg is the Caddy JSON. On Option A, wrap each path in the extra json_extract(msg, '$.MESSAGE') hop.
Slowest endpoints:
SELECT
json_extract(msg, '$.request.uri') AS uri,
COUNT(*) AS hits,
ROUND(AVG(json_extract(msg, '$.duration')), 3) AS avg_seconds,
ROUND(MAX(json_extract(msg, '$.duration')), 3) AS worst
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.msg') = 'handled request'
GROUP BY uri
HAVING hits > 10
ORDER BY avg_seconds DESC
LIMIT 25;
Every 5xx, newest first:
SELECT
"timestamp",
json_extract(msg, '$.request.remote_ip') AS client,
json_extract(msg, '$.request.method') AS method,
json_extract(msg, '$.request.uri') AS uri,
json_extract(msg, '$.status') AS status
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.status') >= 500
ORDER BY "timestamp" DESC
LIMIT 200;
Top talkers, which is how you spot a scraper before your bandwidth bill does:
SELECT
json_extract(msg, '$.request.remote_ip') AS client,
COUNT(*) AS requests,
ROUND(SUM(json_extract(msg, '$.size')) / 1048576.0, 1) AS mb_sent
FROM logs
WHERE json_valid(msg)
GROUP BY client
ORDER BY requests DESC
LIMIT 20;
Login endpoints being probed:
SELECT
json_extract(msg, '$.request.remote_ip') AS client,
json_extract(msg, '$.request.uri') AS uri,
COUNT(*) AS attempts
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.status') IN (401, 403)
GROUP BY client, uri
ORDER BY attempts DESC
LIMIT 50;
Caddy’s ts field is a Unix timestamp with a fractional part. Central Logging’s timestamp column is when the server received the line. Use ts when sub-second ordering matters:
SELECT datetime(json_extract(msg, '$.ts'), 'unixepoch') AS happened, ...
You can also make Caddy emit readable times instead:
format json {
time_format iso8601
}
Pick one and stay with it. Half your queries expecting a float and half expecting a string is a bad afternoon.
Turn queries into alert rules and stop remembering to check.
A bad deploy, caught before a customer emails you:
SELECT COUNT(*) AS errors
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$.status') >= 500
HAVING errors > 10;
Certificate renewal failure is the other one. Caddy renews TLS certificates on its own and it almost always works — which is exactly why nobody notices the time it does not. Those messages go to the journal, not the access log, so point this rule at your journald source and use full-text search:
+caddy +"could not get certificate"
Central Logging evaluates rules every five minutes and notifies through Slack or Telegram. Set a max frequency, or one bad afternoon sends you two hundred messages.
Add log to the site block. Ship through journald for nothing, or to a file for cleaner queries. Then let SQL find the slow endpoints and let alerts find the broken certificate.
Central Logging is a single binary you install on a server you already own. It stores logs in SQLite, needs no JVM and no Docker, and costs $187 once with no per-GB ingest bill. See Deploy, or start with setting up a self-hosted logging server.
💌 Get notified on new features and updates