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.