Homelab logs and metrics with Grafana Alloy: journald to Loki, metrics to Prometheus

My homelab has grown to a good dozen Linux hosts: Proxmox nodes, Docker VMs, a few VPSs. For a while every host had something different collecting logs and metrics: an old OpenTelemetry Collector here, a Docker Loki logging driver there, nothing at all somewhere else. Now every host runs Grafana Alloy with almost the same config:

  • Logs: journald → Loki
  • Host metrics: built-in node exporter → Prometheus
  • Container metrics (Docker hosts only): built-in cAdvisor → Prometheus

Everything is pushed. Prometheus doesn’t need a scrape job per host and the hosts don’t need any open ports. And no, you don’t need Mimir for that: Prometheus can receive remote writes itself.

The setup

  • Loki at 192.168.1.64:3100
  • Prometheus at 192.168.1.65:9090 (installed in an LXC via the Proxmox helper script, so a plain systemd service)
  • Grafana on top of both
  • Alloy installed natively (apt) on each Debian/Ubuntu host

1. Let Prometheus accept remote writes

This is off by default. Prometheus only accepts pushes on /api/v1/write if it’s started with this flag:

Add it to the Prometheus systemd unit (or the compose command:) and restart. Check that it’s active:

No output means the flag isn’t set, and Alloy will log write errors for every batch.

2. Install Alloy

The package runs Alloy as the unprivileged user alloy, with its config in /etc/alloy/config.alloy.

3. Give the alloy user the right groups

Without systemd-journal, Alloy starts without complaint but ships almost no logs. It can only see its own entries.

4. The config

This is the base config every host gets. /etc/alloy/config.alloy:

A few choices that turned out to matter:

  • The host label is set in one place only, in external_labels on both writers. Every metric and log line gets it automatically, so the same Grafana variable $host filters both.
  • journald is the only log source. On Debian/Ubuntu almost everything ends up in the journal anyway. When I also scraped /var/log/syslog, auth.log and kern.log, every line arrived twice.
  • Same labels on every host (host, env, plus job per scrape). A host in testing gets env = "testing" and can be filtered out of alerts.

Restart and watch the log:

5. Docker hosts: container metrics via cAdvisor

Alloy has cAdvisor built in, so you don’t need an extra container. Add to the config:

The catch: the docker group gets Alloy access to the Docker socket, but not to the root-owned paths under /var/lib/docker that cAdvisor reads. The log fills with permission denied and “Failed to create existing container” errors, and you get no container metrics.

The fix is to run Alloy as root on Docker hosts. I write the systemd drop-in directly; systemctl edit silently didn’t save for me once:

Check that it worked:

Empty output from the second command means cAdvisor is happy.

Running Alloy as root is a trade-off. Alloy already reads the whole journal and the Docker socket, so it’s privileged in practice anyway, but it’s still more access. If that bothers you, skip cAdvisor on that host, or run cAdvisor as its own privileged container on 127.0.0.1:8080 and let an unprivileged Alloy scrape it. My rule: cAdvisor = Alloy as root, every other host stays unprivileged.

6. Verify end to end

Metrics in Prometheus (Grafana Explore or curl):

You want one series per scrape job, each with value 1. Logs in Loki (Grafana Explore):

Dashboards that work with these labels out of the box: 1860 (Node Exporter Full) for hosts, 14282 or 19792 for cAdvisor. Set the dashboard’s host variable to the host label.

Gotchas

  • Comments are //, not #. Alloy’s config language is not YAML or shell. One # comment and the whole config fails to parse, and the service stays in “failed”. If Alloy won’t start after an edit, check this first: journalctl -u alloy -n 20.
  • env("HOSTNAME") is empty under systemd. The variable exists in your shell, not in the service’s environment. Your logs then arrive with host="". Use constants.hostname.
  • Missing systemd-journal group: Alloy runs fine but ships almost nothing.
  • Remote-write receiver not enabled in Prometheus: metrics never arrive, and the only hint is write errors in Alloy’s log.
  • cAdvisor permission errors despite the docker group: see section 5.
  • Duplicate logs when you add file scrapes on top of journald. Pick one source.
  • Names are just names. Some of my hosts call the remote-write component "mimir" from an earlier plan, even though it points at Prometheus. That works fine, but forward_to has to use the name the component actually has (prometheus.remote_write.mimir.receiver vs. .prom.receiver). Copying a block between hosts with different names is a classic.
  • Leftovers from earlier experiments: on one host I found an OpenTelemetry Collector from an old test still running and sending data into the void. systemctl list-units --type=service | grep -iE 'otel|promtail|agent' before you start.

Result

Every host now has the same small config, set up in five minutes. Logs and metrics share the same host label, so in Grafana I can jump from a CPU spike straight to the logs of the same host and minute. A new host is: apt install, groups, copy the config, restart. Docker hosts additionally get the cAdvisor block and the root override.

Leave a comment