For quite a while I have been using Caddy as my reverse proxy of choice in my homelab. It is simple to configure, requires minimal resources, and has been very reliable. Plus, it is written in my current language of choice, Go.

In order to visualise requests processed by Caddy I came across GoAccess some time ago. It can run in a terminal but it really shines in the browser with realtime updates via websockets. I am running GoAccess as a docker container on the same VM as Caddy.

One downside to my setup was that all the subdomains served by Caddy were being processed together as one unified view in GoAccess, which meant it was difficult to know which site was being hit etc. I decided to do something about it after a bit of research.

I already had separate Caddy logfiles for each site. This is achieved easily using special blocks known as snippets. In my case, I had a snippet defined like this.

(logging) {
        log {
                output file "/var/log/caddy/access-{args[0]}.log"
        }
}

Which I could then reference inside a site block using an expression like import logging drone

But GoAccess uses websockets and that requires each instance of GoAccess to run on a different port. So I had to configure Caddy to map each site to a port in the docker container. This is achieved using another snippet

(goaccess_route) {
  # args[0] = name (drone, studyweek, tsd, ...)
  # args[1] = port (7894, 7892, 7898, ...)
  # Serve the dashboard HTML from disk:
  handle_path /{args[0]}/* {
    root * /var/www/goaccess/{args[0]}
    file_server
  }

  # WebSocket endpoint still needs to go to the GoAccess process:
  handle /{args[0]}/ws {
    reverse_proxy 127.0.0.1:{args[1]} {
      header_up Host {host}
      header_up X-Forwarded-Proto {scheme}
    }
  }
}

Which I then reference in the block for goaccess, with a separate port and name for each of the sites handled by Caddy

goaccess.domain.com {
    # Landing page only for /
    @root path /
    handle @root {
      root * /var/www/goaccess
      file_server
    }
    import goaccess_route drone      7809
}

I then needed to update my docker-compose.yml for goaccess to look like this. This port range caters for 60 Caddy sites, more than enough for my needs.

services:
  goaccess:
    image: allinurl/goaccess:latest
    restart: unless-stopped
    user: "999:997"

    volumes:
      - "/var/log/caddy:/var/log/caddy:ro"
      - "/var/www/goaccess:/var/www/goaccess:rw"
      - "./data:/data:rw"
      - "./data/goaccess.conf:/data/goaccess.base.conf:ro"

      # multi-instance launcher
      - "./entrypoint.sh:/entrypoint.sh:ro"

    ports:
      - "7800-7860:7800-7860"

    entrypoint: ["/bin/sh", "/entrypoint.sh"]

Lastly, I needed to define the following as entrypoint.sh
If you are doing something similar, make sure to update that BASE_DOMAIN value to match your FQDN.

#!/bin/sh
set -eu

LOG_DIR="/var/log/caddy"
BASE_CONF="/data/goaccess.base.conf"
TMP_CONF_DIR="/tmp/goaccess"

BASE_DOMAIN="${GOACCESS_DOMAIN:-goaccess.domain.com}"
PORT_START="${PORT_START:-7800}"
PORT_END="${PORT_END:-7860}"

shutdown() {
  echo "Caught shutdown signal, stopping GoAccess cleanly..."
  # SIGINT is the closest to Ctrl+C, tends to trigger clean flush
  for p in $PIDS; do
    kill -INT "$p" 2>/dev/null || true
  done
  wait || true
  echo "All GoAccess processes stopped."
  exit 0
}

trap shutdown INT TERM

mkdir -p "$TMP_CONF_DIR"

echo "Using LOG_DIR=$LOG_DIR"
echo "Using BASE_CONF=$BASE_CONF"
echo "Using port range ${PORT_START}-${PORT_END}"
echo "Using BASE_DOMAIN=$BASE_DOMAIN"

if [ ! -f "$BASE_CONF" ]; then
  echo "ERROR: Base config not found or not a file: $BASE_CONF"
  echo "Inside container, /data contains:"
  ls -la /data || true
  exit 1
fi

PIDS=""
i=0

# Only match regular files
for logfile in $(find "$LOG_DIR" -maxdepth 1 -type f -name 'access-*.log' | sort); do
  base="$(basename "$logfile")"
  name="${base#access-}"
  name="${name%.log}"

  port=$((PORT_START + i))
  if [ "$port" -gt "$PORT_END" ]; then
    echo "ERROR: port range exhausted (${PORT_START}-${PORT_END}). Too many log files."
    exit 1
  fi

  conf="${TMP_CONF_DIR}/${name}.conf"
  outdir="/var/www/goaccess/${name}"
  outfile="${outdir}/index.html"

  mkdir -p "$outdir"

  # strip base settings that must be per-site
  grep -vE '^(ws-url|port|log-file|output|db-path)[[:space:]]' "$BASE_CONF" > "$conf"

  {
    echo ""
    echo "# ---- auto-generated per-site settings ----"
    echo "log-file ${logfile}"
    echo "port ${port}"
    echo "addr 0.0.0.0"
    echo "origin https://${BASE_DOMAIN}"
    echo "ws-url wss://${BASE_DOMAIN}:443/${name}/ws"
    echo "output ${outfile}"
    echo "db-path /data/${name}"
  } >> "$conf"

  mkdir -p "/data/${name}"

  echo "Starting goaccess '${name}' on :${port} reading ${logfile}"
  goaccess --persist --no-global-config --config-file="$conf" &
  PIDS="$PIDS $!"

  i=$((i + 1))
done

if [ "$i" -eq 0 ]; then
  echo "ERROR: no logs found matching ${LOG_DIR}/access-*.log"
  ls -la "$LOG_DIR" || true
  exit 1
fi

wait

Lastly, I needed a static index.html page created in /var/www/goaccess to link to each of the GoAccess instances. Something like this

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>GoAccess dashboards</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body {
      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #0f172a;
      color: #e5e7eb;
      padding: 2rem;
    }
    h1 { margin-bottom: 0.5rem; }
    h2 {
      margin-top: 1.5rem;
      margin-bottom: 0.4rem;
      font-size: 1.1rem;
      color: #cbd5f5;
    }
    ul {
      list-style: none;
      padding: 0;
      margin: 0 0 0.8rem 0;
      max-width: 600px;
    }
    li { margin: 0.25rem 0; }
    a {
      color: #38bdf8;
      text-decoration: none;
      font-weight: 500;
    }
    a:hover { text-decoration: underline; }
    .muted {
      color: #94a3b8;
      font-size: 0.9rem;
      margin-bottom: 1rem;
    }
  </style>
</head>
<body>

<h1>GoAccess dashboards</h1>
<div class="muted">Access statistics per Caddy virtual host</div>

<h2>Core / Infra</h2>
<ul>
  <li><a href="/drone/">Drone</a></li>
  <li><a href="/monitor/">Monitor</a></li>
</ul>

</body>
</html>

I hope that helps you analyse traffic patterns per site instead of aggregated across all sites served by Caddy.