Skip to content

Logging & Metrics Stack

MDH Media homelab uses Grafana Loki for centralized log aggregation, Prometheus for metrics storage, and Grafana Alloy as the unified collection agent for both logs and metrics.

Moved to Docker (July 2026)

Grafana, Loki and Prometheus now run as Arcane git-sync stacks under stacks/monitoring/ on the Docker LXC (192.168.1.241, CT 101 on pve1) — Grafana :3300, Loki :3100, Prometheus :9090. The old native-install grafana LXC (.230, CT 118 on pve2) is stopped and retired; there is no longer a native install-loki.sh / install-prometheus.sh. The Alloy host-agent installers live at host-agents/ — run them on any non-docker VM/LXC to ship its logs+metrics to the collector on 192.168.1.241. Dashboards are file-provisioned from the repo, not imported manually.

Architecture

flowchart TD
    subgraph Hosts[Monitored non-docker Hosts]
        A1[VM/LXC 1<br/>Alloy]
        A2[VM/LXC 2<br/>Alloy]
        A3[Caddy<br/>Alloy]
    end

    subgraph Collector[Docker LXC - 192.168.1.241]
        Loki[Loki<br/>:3100]
        Prom[Prometheus<br/>:9090]
        Graf[Grafana<br/>:3300]
    end

    A1 -->|logs| Loki
    A2 -->|logs| Loki
    A3 -->|logs| Loki
    A1 -->|metrics| Prom
    A2 -->|metrics| Prom
    A3 -->|metrics| Prom
    Loki --> Graf
    Prom --> Graf

Services

Grafana Loki

  • Host: Docker LXC (CT 101, pve1)
  • IP: 192.168.1.241
  • Port: 3100
  • Role: Log aggregation and storage
  • Stack: stacks/monitoring/loki/ (Arcane git-sync stack)
  • Config: stacks/monitoring/loki/loki-config.yml

Prometheus

  • Host: Docker LXC (CT 101, pve1)
  • IP: 192.168.1.241
  • Port: 9090
  • Role: Metrics storage (via remote_write API)
  • Stack: stacks/monitoring/prometheus/ (Arcane git-sync stack)
  • Config: stacks/monitoring/prometheus/prometheus.yml

Grafana Alloy

  • Role: Unified log and metrics collection agent
  • Config: /etc/alloy/config.alloy
  • Service: systemctl status alloy
  • UI: http://localhost:12345 (when enabled)

Collects:

  • System metrics: CPU, memory, disk, filesystem, load, network
  • Systemd journal logs
  • Syslog and auth logs
  • Application-specific logs (Caddy, Docker, etc.)

Installation

Loki & Prometheus are Docker stacks now

The collectors are no longer installed natively. Loki, Prometheus and Grafana run as Arcane git-sync stacks under stacks/monitoring/{loki,prometheus,grafana}/ on the Docker LXC (192.168.1.241) and deploy via GitOps on merge to main — there is no install-loki.sh / install-prometheus.sh to run. The scripts below install the Alloy telemetry agent on other (non-docker) hosts so they ship their logs+metrics to that collector.

Alloy (Generic Host)

Run on any non-docker VM/LXC to collect system logs and metrics and forward them to the collector on 192.168.1.241:

curl -O https://raw.githubusercontent.com/MDHMatt/mdhmedia/main/host-agents/install-alloy.sh
chmod +x install-alloy.sh
sudo ./install-alloy.sh                 # ships to 192.168.1.241 by default

# Or pass the collector IP explicitly
sudo ./install-alloy.sh 192.168.1.241

Features:

  • Collects system metrics (CPU, memory, disk, filesystem, load, network)
  • Forwards metrics to Prometheus via remote_write
  • Collects systemd journal, syslog, and auth logs
  • Forwards logs to Loki

Alloy (Caddy Reverse Proxy)

Specialized script for the Caddy VM (CT 112, pve1) with JSON access log parsing:

curl -O https://raw.githubusercontent.com/MDHMatt/mdhmedia/main/host-agents/install-alloy-caddy.sh
chmod +x install-alloy-caddy.sh
sudo ./install-alloy-caddy.sh 192.168.1.241

Features:

  • Collects system metrics (CPU, memory, disk, filesystem, load, network)
  • Parses Caddy JSON access logs at /var/lib/caddy/logs/access.log
  • Extracts labels for filtering: request_host, request_method, status
  • Collects Caddy systemd service logs
  • Creates human-readable log lines from JSON

Manual Installation

# Add Grafana repository (Debian/Ubuntu - DEB822 format)
sudo mkdir -p /etc/apt/keyrings
sudo wget -q -O /etc/apt/keyrings/grafana.asc https://apt.grafana.com/gpg-full.key
sudo chmod 644 /etc/apt/keyrings/grafana.asc

cat << 'EOF' | sudo tee /etc/apt/sources.list.d/grafana.sources
Types: deb
URIs: https://apt.grafana.com
Suites: stable
Components: main
Signed-By: /etc/apt/keyrings/grafana.asc
EOF

# Install
sudo apt-get update
sudo apt-get install alloy

# Configure and start
sudo systemctl enable --now alloy

Configuration

Default Log Sources

Source Type Labels
Systemd Journal All services job=systemd-journal, unit=<service>
/var/log/syslog System logs job=syslog
/var/log/auth.log Auth logs job=auth
/var/log/messages System messages job=messages

Caddy Log Labels

Label Description Example
request_host Destination service grafana.mdhmedia.uk
request_method HTTP method GET, POST
status HTTP status code 200, 404, 502

Adding Custom Log Sources

Edit /etc/alloy/config.alloy:

local.file_match "myapp" {
  path_targets = [
    {
      __path__ = "/var/log/myapp/*.log",
      job      = "myapp",
    },
  ]
}

loki.source.file "myapp" {
  targets    = local.file_match.myapp.targets
  forward_to = [loki.write.default.receiver]
  tail_from_end = true
}

Reload configuration:

sudo systemctl reload alloy

Node Metrics Configuration

The scripts configure system metrics collection using prometheus.exporter.unix:

prometheus.exporter.unix "node" {
  set_collectors = ["cpu", "meminfo", "diskstats", "filesystem", "loadavg", "netdev", "netclass", "uname", "stat", "time", "vmstat", "hwmon", "thermal_zone", "cpufreq"]
  filesystem {
    fs_types_exclude = "^(autofs|binfmt_misc|bpf|cgroup2?|cifs|configfs|debugfs|devpts|devtmpfs|fuse.*|fusectl|hugetlbfs|iso9660|mqueue|nfs4?|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|smb3|squashfs|sysfs|tracefs)$"
  }
}

prometheus.scrape "node" {
  targets    = prometheus.exporter.unix.node.targets
  forward_to = [prometheus.remote_write.default.receiver]
  scrape_interval = "15s"
  job_name   = "node"
}

Network filesystems are excluded (PR #140)

The fs_types_exclude regex above adds the network fstypes cifs, nfs4?, smb3 and fuse.*. A dead Holly CIFS mount used to make statfs() block past the 10 s scrape timeout, failing the whole node scrape and blanking a host's node_* metrics (it took out pve1 + CT 101 when Holly went down 2026-07-25). Excluding network fstypes makes the collector resilient to a NAS outage. The docker host's monitoring/alloy/config.alloy uses the leaner "^(cifs|nfs4?|smb3|fuse.*)$"; the Unraid agent (install-alloy-unraid.sh) deliberately keeps fuse so Holly's /mnt/user (an shfs FUSE mount) still reports array fill.

netclass + time/stat collectors (2026-07-30)

Every Alloy config now enables the netclass collector — node_network_speed_bytes exposes each interface's live negotiated link speed, which feeds the NetworkLinkDegraded alert (fires when a physical link drops to <=100 Mb/s). The time and stat collectors were added to the Holly and Caddy configs at the same time: the HostMetricsStale staleness alert keys off node_time_seconds, and Holly was invisible to it before — all five always-on hosts (caddy, docker-pve1, holly, pve1, pve2) are now covered, plus pve3 while awake. The exact collector list varies slightly per host: the generic installer (above) also enables hwmon/thermal_zone/cpufreq for bare-metal thermals, and the Unraid agent adds textfile.

Example Configurations

Docker Container Logs

local.file_match "docker" {
  path_targets = [
    {
      __path__         = "/var/lib/docker/containers/*/*.log",
      job              = "docker",
      __path_exclude__ = "/var/lib/docker/containers/*/*-json.log.*.gz",
    },
  ]
}

loki.source.file "docker" {
  targets    = local.file_match.docker.targets
  forward_to = [loki.write.default.receiver]
  tail_from_end = true
}

Nginx Logs

local.file_match "nginx" {
  path_targets = [
    {__path__ = "/var/log/nginx/access.log", job = "nginx", type = "access"},
    {__path__ = "/var/log/nginx/error.log", job = "nginx", type = "error"},
  ]
}

loki.source.file "nginx" {
  targets    = local.file_match.nginx.targets
  forward_to = [loki.write.default.receiver]
  tail_from_end = true
}

Querying Logs

LogQL Examples

# All logs from a specific host
{host="docker-pve1"}

# Systemd journal logs for a specific unit
{job="systemd-journal", unit="nginx.service"}

# Search for errors across all hosts
{env="homelab"} |= "error"

# Auth failures
{job="auth"} |= "Failed password"

# Top error-producing hosts (last hour)
sum by (host) (count_over_time({env="homelab"} |= "error" [1h]))

# SSH login attempts
{job="auth"} |~ "sshd.*Accepted|sshd.*Failed"

# Systemd service failures
{job="systemd-journal"} |= "Failed to start"

# Caddy requests by service
{job="caddy", request_host="grafana.mdhmedia.uk"}

# Caddy 5xx errors
{job="caddy", status=~"5.."}

Auth logs on journald-only hosts

job="auth" returns 0 series on hosts that collect only journald. Query SSH authentication events with {job="systemd-journal"} |= "sshd", or use the Security dashboard.

Querying Metrics

PromQL Examples

# CPU usage by host (percentage)
100 - (avg by(host) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)

# Memory usage by host (percentage)
100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)

# Disk usage by host and mountpoint (percentage)
100 - (node_filesystem_avail_bytes / node_filesystem_size_bytes * 100)

# Network traffic (bytes/sec)
rate(node_network_receive_bytes_total[5m])
rate(node_network_transmit_bytes_total[5m])

# System load average (1 minute)
node_load1

# Top 5 hosts by CPU usage
topk(5, 100 - (avg by(host) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100))

# Hosts with disk usage > 80%
node_filesystem_avail_bytes / node_filesystem_size_bytes * 100 < 20

# All metrics from a specific host
{host="caddy"}

Dashboards

Canonical catalog: see Monitoring → Dashboard catalog

The 2026-07-26 Grafana overhaul (PR #142) reorganised all 11 dashboards into six folders, made Homelab Command Center the home dashboard, and refocused two of the boards below; on 2026-07-30 (PR #172) they were formally renamed — Homelab Logs → Log Explorer and Homelab Overview → Fleet Trends — and every data panel gained a description. The per-panel lists in this section predate that rebuild and are kept only as a rough guide; monitoring.md is the authoritative dashboard catalog.

Log Explorer (formerly Homelab Logs)

  • File: stacks/monitoring/grafana/provisioning/dashboards/Logs & Diagnostics/loki-homelab-logs.json
Section Panels
Overview Total logs, active hosts, errors, warnings, auth failures, successful logins
Volume Log volume by host, log volume by job
Errors Error/warning trends, errors by host, errors by job
Security SSH login attempts (success/fail), failed logins by host
Systemd Logs by systemd unit
Browser Full log browser with search, error-only log view

Variables: host, job, search

Caddy Access Logs

  • File: stacks/monitoring/grafana/provisioning/dashboards/Edge & Security/caddy-access-logs.json
Section Panels
Traffic Overview Total requests, 2xx/3xx/4xx/5xx counts, requests/sec
Status Codes Requests by status code over time (stacked)
Services Requests by service/host, top services bar chart
Distribution Request methods pie chart, status code donut chart
Error Analysis Error rate trends, errors by service, error log viewer
Caddy Service Caddy systemd service errors/warnings
Access Browser Full access log browser with filters

Variables: host (service), status, search

  • File: stacks/monitoring/grafana/provisioning/dashboards/Infrastructure/homelab-overview.json
Section Panels
Infrastructure Health Total hosts, hosts online, errors (24h), warnings (24h)
Host Status Host status table (up/down, last seen)
Systemd Services Service status, recent failures
Security Auth events, failed logins
Caddy Requests, errors, traffic by service
System Metrics CPU, memory, disk, network (Prometheus)

Variables: host, job, timeRange

Dashboard provisioning

These JSON files are file-provisioned from the repo — the Grafana stack mounts stacks/monitoring/grafana/provisioning/dashboards/ and loads them on startup (see provisioning/dashboards/dashboards.yml), so a merge to main redeploys them via GitOps. No manual import. After editing a dashboard in the Grafana UI, export the JSON back into that folder to keep it version-controlled.

Troubleshooting

Alloy Not Starting

sudo systemctl status alloy
sudo journalctl -u alloy -f
alloy fmt /etc/alloy/config.alloy  # Check syntax and format

Common config errors: - Unrecognized attribute: Options like fs_types_exclude must be inside nested blocks (e.g., filesystem { }) - Missing quotes: String values must be quoted - Invalid regex: Check escape characters in patterns

No Logs Appearing in Loki

  1. Check Alloy is running: systemctl status alloy
  2. Test Loki connectivity: curl http://192.168.1.241:3100/ready
  3. Check firewall: Ensure port 3100 is open
  4. Verify permissions:
    sudo usermod -aG systemd-journal alloy
    sudo usermod -aG adm alloy
    sudo systemctl restart alloy
    

High Memory Usage (Alloy)

Reduce batch size in /etc/alloy/config.alloy:

loki.write "default" {
  endpoint {
    url = "http://192.168.1.241:3100/loki/api/v1/push"
    batch_size = "512KiB"
    batch_wait = "2s"
  }
}

Loki Not Starting

Loki runs as a docker container on the Docker LXC (CT 101, 192.168.1.241). Check it via pve1:

ssh pve1 "pct exec 101 -- docker ps --filter name=loki"
ssh pve1 'pct exec 101 -- docker logs loki --tail 50'

Config errors show up in the container logs. The config lives in the repo at stacks/monitoring/loki/loki-config.yml — edit it there and merge to main; Arcane GitOps redeploys the stack.

Loki High Disk Usage

Adjust retention in stacks/monitoring/loki/loki-config.yml (deploys via merge to main / Arcane GitOps):

limits_config:
  retention_period: 720h  # 30 days

compactor:
  retention_enabled: true
  retention_delete_delay: 2h

Loki Out of Memory

Reduce ingester settings in stacks/monitoring/loki/loki-config.yml:

ingester:
  chunk_idle_period: 30m
  max_chunk_age: 1h
  chunk_target_size: 1048576

Prometheus Not Starting

Prometheus runs as a docker container on the Docker LXC (CT 101, 192.168.1.241). Check it via pve1:

ssh pve1 "pct exec 101 -- docker ps --filter name=prometheus"
ssh pve1 'pct exec 101 -- docker logs prometheus --tail 50'

The config lives in the repo at stacks/monitoring/prometheus/prometheus.yml. Note that the Prometheus git stack does not auto-reload config changes — after a merge to main, the container needs a redeploy/restart to pick them up.

No Metrics in Prometheus

  1. Check Alloy is running: systemctl status alloy
  2. Test Prometheus connectivity: curl http://192.168.1.241:9090/-/ready
  3. Check firewall: Ensure port 9090 is open
  4. Verify remote_write is enabled in Prometheus (check for --web.enable-remote-write-receiver flag)
  5. Check Alloy config has correct Prometheus URL

Prometheus High Disk Usage

Adjust retention via the --storage.tsdb.retention.time flag in the Prometheus compose file (stacks/monitoring/prometheus/docker-compose-prometheus.yml):

--storage.tsdb.retention.time=15d

Then merge to main — changing the compose file triggers an Arcane GitOps redeploy of the stack (a plain prometheus.yml config change would additionally need a manual restart, since Prometheus does not auto-reload its config).

References