centralized logging with loki, grafana, and alloy
Loki is Grafana's log aggregation system — like Elasticsearch but cheaper to run and purpose-built for logs rather than search. here's how it's set up to collect container logs from two servers and firewall logs from pfSense.
why loki over elk
Elasticsearch indexes everything by default, which makes it fast to search but expensive on disk and memory. Loki takes a different approach: it stores logs as compressed chunks and only indexes labels (metadata), not the log content itself. searching requires scanning chunks, but for a homelab that's fine. the resource footprint is significantly smaller.
the architecture
pfSense (syslog UDP 514)
↓
10.0.3.11: Alloy ← Docker socket (local containers)
↓
Loki (log storage)
↑
10.0.3.21: Alloy → ships GPU server container logs
Alloy is Grafana's OpenTelemetry-based collector. it runs on both servers — one instance collects logs locally and also receives pfSense syslog, the other ships logs from the second host to the central Loki instance.
labels
Loki queries filter on labels, not content. every log stream gets labeled at collection time:
| Label | Values | Source |
|---|---|---|
host |
original-server, gpu-server |
which machine the container runs on |
container |
container name | Docker metadata |
job |
docker |
all container logs |
service_name |
syslog |
pfSense logs |
a query for all errors across all containers on both hosts:
{job="docker"} |~ `(?i)\berror\b|exception|fatal` != `[debug]`
a query for a specific container:
{container="jellyfin"}
pfsense syslog gotcha
pfSense can send syslog in RFC3164 or RFC5424 format. Alloy only accepts RFC5424. if you set pfSense to RFC3164, Alloy logs expecting a version value and drops everything silently.
in pfSense: Status → System Logs → Settings → Remote Logging → format: RFC5424. it's not the default.
alloy config
Alloy config is River format (Grafana's own DSL). the relevant bits for Docker log collection:
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.containers.targets
forward_to = [loki.write.default.receiver]
labels = { host = "gpu-server", job = "docker" }
}
loki.write "default" {
endpoint {
url = "http://10.0.3.11:3100/loki/api/v1/push"
}
}
portainer + config files
Portainer deploys stacks from git repos. it copies the compose file to the agent and runs it — but it doesn't copy other files. so Alloy config files have to live on the host at absolute paths and get bind-mounted:
volumes:
- /opt/syslog/alloy-config.alloy:/etc/alloy/config.alloy
relative paths (./config.alloy) cause Docker to create an empty directory instead of mounting the file. absolute paths only.
querying via api
Loki has a simple HTTP API — useful for scripting or for pulling logs programmatically:
curl -s "http://loki-host:3100/loki/api/v1/query_range" \
-G \
--data-urlencode 'query={container="my-container"}' \
--data-urlencode "start=$(date -d '1 hour ago' +%s)000000000" \
--data-urlencode "end=$(date +%s)000000000" \
--data-urlencode "limit=50"
timestamps are nanoseconds. the default Loki install has no auth, so keep it LAN-only.
eli5
Loki is like a librarian who doesn't read every book — they just file them in boxes with labeled stickers on the outside. when you want to find something, they pull the right box and scan through it. this is cheaper and faster to set up than Elasticsearch, which reads every single word of every log and memorizes it all. for a homelab where you're not searching billions of logs per second, Loki's approach is plenty fast and uses way less memory. Alloy is the delivery person collecting logs from each server and dropping them in the right boxes. Grafana is the reading room where you go to search and look at dashboards. pfSense (the router) can send its logs too, but it has to send them in the right format or Alloy will silently throw them away.