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.
Leave a Reply