GoAccess is a fast, terminal-based log analyzer that can generate self-contained HTML reports. Getting it working with Caddy takes a bit of setup because Caddy’s JSON log format mixes HTTP access entries with system/startup messages, and the built-in caddy preset in GoAccess requires a newer version than most distros ship.
Installing GoAccess from Source
The version in Ubuntu’s package repos is too old to be useful. Build from source instead:
sudo apt install libncursesw5-dev libgeoip-dev gcc make wget
wget https://tar.goaccess.io/goaccess-1.9.3.tar.gz
tar -xzvf goaccess-1.9.3.tar.gz
cd goaccess-1.9.3
./configure --enable-utf8 --enable-geoip=legacy
make
sudo make install
Configuring Caddy to Write Clean Log Files
By default Caddy logs to stdout, which systemd forwards to syslog. This mixes HTTP access entries with system messages and makes log analysis painful. Instead, redirect output directly to a dedicated file.
In the systemd service file, add these two lines to the [Service] section:
StandardOutput=append:/var/log/caddy/access.log
StandardError=append:/var/log/caddy/access.log
Then update the Caddyfile log snippet to write to the same file and filter to HTTP access entries only:
(log) {
log {
output file /var/log/caddy/access.log
format json
include http.log.access
}
}
The include http.log.access directive is important — without it, Caddy writes startup messages, deprecation warnings, and TLS events into the same file, which will confuse GoAccess.
Make sure the log directory and file are owned by the user Caddy runs as:
sudo chown app:app /var/log/caddy
sudo chmod 755 /var/log/caddy
sudo chown app:app /var/log/caddy/access.log
sudo systemctl daemon-reload
sudo systemctl restart caddy
Setting Up Log Rotation
Create /etc/logrotate.d/caddy:
/var/log/caddy/access.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 644 app app
postrotate
systemctl reload caddy
endscript
}
The delaycompress option means the most recently rotated file stays uncompressed for one cycle, giving Caddy time to finish writing to it before it gets gzipped. The postrotate reload signals Caddy to open a fresh file handle after rotation.
Generating Reports
Caddy’s JSON structure requires a bit of preprocessing with jq before GoAccess can parse it. The access entries are JSON objects — the timestamp is a float, and the User-Agent and Referer fields need quoting since they contain spaces.
grep '"msg":"handled request"' /var/log/caddy/access.log.1 | \
jq -r '[(.ts|floor|tostring), .request.remote_ip, .request.method, .request.uri, (.status|tostring), (.size|tostring), ("\"" + (.request.headers["User-Agent"][0] // "-") + "\""), ("\"" + (.request.headers["Referer"][0] // "-") + "\"")] | join(" ")' | \
goaccess - \
--log-format='%x %h %m %U %s %b "%u" "%R"' \
--datetime-format='%s' \
--no-progress \
-o /var/log/caddy/reports/$(date +%Y-%m-%d).html
The --no-progress flag is required when piping input via stdin — without it GoAccess exits silently without writing the report.
Automating Daily Reports with Cron
Create a script at /home/app/bin/caddy-report.sh:
#!/bin/bash
DATE=$(date +%Y-%m-%d)
grep '"msg":"handled request"' /var/log/caddy/access.log | \
jq -r '[(.ts|floor|tostring), .request.remote_ip, .request.method, .request.uri, (.status|tostring), (.size|tostring), ("\"" + (.request.headers["User-Agent"][0] // "-") + "\""), ("\"" + (.request.headers["Referer"][0] // "-") + "\"")] | join(" ")' | \
goaccess - \
--log-format='%x %h %m %U %s %b "%u" "%R"' \
--datetime-format='%s' \
--no-progress \
-o /var/log/caddy/reports/$DATE.html
chmod +x /home/app/bin/caddy-report.sh
Add to crontab to run just before logrotate at midnight:
50 23 * * * /home/app/bin/caddy-report.sh
Reports accumulate in /var/log/caddy/reports/ as dated HTML files.
Serving Reports Locally via Caddy
Rather than copying reports somewhere or using scp to view them, serve the reports directory directly from Caddy on a non-standard port, restricted to the local network only.
Add this to your Caddyfile:
:32000 {
root * /var/log/caddy/reports
file_server browse
@notlocal not remote_ip 192.168.0.0/16
respond @notlocal 401
}
Any request from outside 192.168.0.0/16 gets an unauthorized response before any content is served. Browse to http://192.168.10.5:32000 from any machine on the local network to see the report listing.