Loki for Log Aggregation: Ship, Query, and Correlate Your Logs
Your Prometheus dashboards are clean. CPU, memory, disk, request rates — all graphed and alerting. Then something breaks at 2am and the metrics tell you that something happened, but not why. The error rate spiked. Memory climbed. A container restarted three times. What actually went wrong? The answer is in the logs — and if your logs are scattered across a dozen hosts with no central place to query them, you are SSH-ing into boxes and grepping files while the incident is still open.
Loki fixes this. It gives you a single place to ship, store, and query logs from every host, container, and service in your infrastructure. And because it was built by the same team as Grafana and Prometheus, it integrates cleanly with everything you already have. One query interface, one dashboard, logs and metrics side by side.
This guide builds a complete Loki stack from scratch — Docker Compose configuration, log shipping with Promtail and Alloy, LogQL query language, Grafana integration, and alerting rules. By the end, you will be able to query logs across your entire infrastructure from a single browser tab and correlate them with Prometheus metrics in the same dashboard.
Part 1: What Is Loki and Why Use It?
Loki is often described as “Prometheus, but for logs.” That framing is useful because it captures the key design decision: Loki does not index log content. It only indexes the labels attached to log streams.
When a log line arrives at Loki, the label set is indexed (things like host, job, container_name, level). The actual text of the log line is compressed and stored as a chunk on disk or in object storage. When you query, Loki first uses the label index to find the relevant chunks, then decompresses and scans the log content to match your query. This is fundamentally different from Elasticsearch, which tokenizes and inverts every word in every log line into a massive index.
The practical consequence: Loki is dramatically cheaper to run. An Elasticsearch cluster that indexes 10GB of logs per day needs serious CPU and RAM to maintain the inverted index. A Loki installation handling the same volume runs on a modest single machine because it is mostly doing I/O — fetching chunks and scanning them — rather than index maintenance.
The tradeoff: If you rely on full-text search across arbitrary fields without knowing label context, Elasticsearch is faster. Google-style “find any log containing this string anywhere across all logs ever” is not Loki’s sweet spot. Loki is better framed as: you know what service you care about, you know roughly when the event happened, and you want to filter and extract structured data from the relevant log lines.
The Grafana LGTM Stack
Loki is one component in the broader Grafana observability ecosystem, sometimes called the LGTM stack:
- L — Loki (logs)
- G — Grafana (visualization)
- T — Tempo (distributed traces)
- M — Mimir or Prometheus (metrics)
All four are designed to interoperate. In Grafana you can jump from a Prometheus alert to the relevant Loki logs in one click. A log line containing a trace ID can link directly into Tempo. The stack is modular — you can adopt Loki without changing your existing Prometheus and Grafana setup.
When Loki Is Not the Right Choice
Loki is the right choice for most operational log aggregation. It is not the right choice if:
- You need full-text search across log content without knowing which service or host the log came from
- Your compliance requirements demand indexed, searchable audit logs that can be queried by security teams without knowing label structure
- You are ingesting structured application logs that already have dozens of fields and you want to query any of them like a database
For those cases, Elasticsearch or OpenSearch remains the better option. For the vast majority of homelab and small-team production use cases — centralizing Docker logs, monitoring auth events, analyzing web server access, alerting on application errors — Loki is efficient, cost-effective, and integrates cleanly with the stack you already have.
Part 2: Architecture
Core Components
Loki is composed of several internal components. In the default monolithic deployment they all run inside a single binary, which is what you want for homelab and small production deployments.
Distributor — Receives incoming log streams from agents. Validates labels, applies rate limiting, and fans out to ingesters using consistent hashing so the same stream always hits the same ingester.
Ingester — Holds recent log chunks in memory. Flushes completed chunks to the storage backend. Replication across ingesters provides durability before a flush completes.
Querier — Handles query and query_range API requests. Fetches chunks from storage (and from ingesters for very recent data), decompresses them, and evaluates the LogQL expression against the content.
Query Frontend — Optional component that sits in front of queriers. Splits large queries into smaller time shards, executes them in parallel, caches results, and merges responses. Important for performance at scale.
Compactor — Runs as a background process, merging small chunks into larger ones and enforcing retention policies. Reduces storage costs and speeds up queries over older data.
Ruler — Evaluates LogQL alerting and recording rules on a schedule. Sends firing alerts to Alertmanager, exactly like Prometheus does for metric-based alerts.
Monolithic vs Microservices Mode
Monolithic mode (single binary with -target=all) runs all components in one process. This is the right choice for everything up to tens of gigabytes of logs per day. Configuration is simpler, the operational footprint is minimal, and vertical scaling takes you a long way.
Microservices mode deploys each component separately (or in small logical groups called “simple scalable” mode). This enables horizontal scaling of individual bottleneck components — typically ingesters and queriers under high load. Kubernetes operators and Helm charts exist for this deployment model. For anything running on a single machine or a small cluster, you will not need it.
Storage Backends
| Backend | Use Case |
|---|---|
filesystem |
Development, single-node homelabs |
S3 / GCS / Azure Blob |
Production, durable multi-region storage |
MinIO |
Self-hosted S3-compatible, on-premises production |
The filesystem backend is perfectly fine for a homelab with adequate disk space. For anything you care about losing, use object storage — even a small MinIO instance on a NAS is better than chunks on a Docker volume.
Log Streams and Labels
A log stream is the fundamental unit in Loki. It is defined entirely by its set of labels. Any two log lines that share the exact same label set belong to the same stream. This is exactly how Prometheus time series work — the label set is the identity.
stream: {job="nginx", host="web01", env="prod"}
stream: {job="nginx", host="web02", env="prod"}
These are two different streams, even though they both have job="nginx". Each stream is stored as a sequence of compressed chunks, ordered by timestamp. Within a stream, log lines must arrive in timestamp order (or very close to it — Loki allows configurable out-of-order tolerance).
Architecture Diagram
┌─────────────────────────────────────────────────────────────────┐
│ Log Sources │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌───────────┐ │
│ │ /var/log │ │ Docker │ │ Systemd │ │ App HTTP │ │
│ │ files │ │ containers│ │ Journal │ │ /push │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬─────┘ │
└────────┼───────────────┼───────────────┼────────────────┼────────┘
│ │ │ │
└───────────────┴───────────────┴────────────────┘
│
▼
┌──────────────────────────────────┐
│ Promtail / Grafana Alloy │
│ (label assignment, pipeline │
│ stages, HTTP push to Loki) │
└──────────────────┬───────────────┘
│ HTTP POST /loki/api/v1/push
▼
┌──────────────────────────────────┐
│ Loki (:3100) │
│ │
│ Distributor → Ingester → Chunks │
│ Compactor (background) │
│ Ruler (alerting rules) │
│ Storage: filesystem / S3 / MinIO │
└──────────────────┬───────────────┘
│ LogQL query
▼
┌──────────────────────────────────┐
│ Grafana (:3000) │
│ Explore │ Dashboards │ Alerting │
│ (Loki datasource) │
└──────────────────────────────────┘
│
Ruler ──►│ Alertmanager (:9093)
│ PagerDuty / Slack / Email
Part 3: Installation with Docker Compose
The following stack brings up Loki, Promtail, and Grafana with Loki auto-provisioned as a datasource. Create a project directory and place these files inside it.
Directory Layout
loki-stack/
├── docker-compose.yml
├── loki-config.yaml
├── promtail-config.yaml
└── grafana/
└── provisioning/
└── datasources/
└── loki.yaml
docker-compose.yml
|
|
loki-config.yaml
|
|
Grafana datasource provisioning (grafana/provisioning/datasources/loki.yaml)
|
|
Bring the stack up:
|
|
Part 4: Log Shipping with Promtail
Promtail is the official Loki log shipping agent. It is a lightweight binary (or container) that tails log files, reads from the systemd journal, discovers Docker container logs, processes each line through a pipeline, and ships the results to Loki via HTTP.
Promtail Config Anatomy
A Promtail configuration has four top-level sections:
server: # Promtail's own HTTP server (for metrics, health)
positions: # File tracking state — which byte offset we've read to
clients: # Where to ship logs (Loki URL + auth)
scrape_configs: # What to tail and how to label it
Complete promtail-config.yaml
|
|
Pipeline Stages Explained
Promtail processes each log line through a sequence of pipeline stages before sending it to Loki. Stages run in order and can mutate the log line or its labels.
| Stage | What It Does |
|---|---|
regex |
Extract named capture groups from the log line using a Go regex |
json |
Parse the log line as JSON, extract fields into the pipeline |
logfmt |
Parse key=value formatted log lines |
labels |
Promote extracted fields to Loki labels |
label_keep / label_drop |
Prune labels before sending |
timestamp |
Parse and set the log timestamp from an extracted field |
output |
Replace the log line content with an extracted field |
template |
Transform extracted values using Go templates |
drop |
Discard log lines matching a condition |
multiline |
Join multiple lines into one (for stack traces) |
pack |
Pack multiple labels/fields into the log line as JSON |
Multiline example — grouping Java stack traces into a single log entry:
|
|
Part 5: Grafana Alloy — The Modern Replacement for Promtail
Grafana Alloy is the unified successor to both Promtail and Grafana Agent. Released in 2024, it combines log shipping (Promtail’s functionality), metrics collection, and trace forwarding into a single binary with a composable configuration language.
Should You Use Alloy or Promtail?
- New deployments: Use Alloy. It is the supported path forward and supports everything Promtail does, plus metrics scraping and trace forwarding.
- Existing Promtail installations: Promtail continues to work and receive bug fixes. There is no urgent need to migrate, but plan to do so when it makes sense.
- Kubernetes: Alloy’s Helm chart is the recommended deployment mechanism for K8s log collection going forward.
Alloy Configuration Basics
Alloy uses a configuration language called Alloy config (formerly called River). It is a declarative, component-based language where each block is a typed component that exposes exports and accepts arguments.
|
|
Run Alloy in Docker:
|
|
Part 6: Other Log Shipping Options
Docker Logging Driver
The Loki Docker logging driver sends container logs directly from Docker without a separate agent. Install it once on the host:
|
|
Configure it per container in your Compose file:
|
|
Or set it as the default Docker logging driver in /etc/docker/daemon.json:
|
|
Note: The Docker logging driver does not buffer to disk. If Loki is unreachable, logs are lost. For production, prefer Promtail or Alloy with their disk-based position tracking.
Vector.dev
Vector is a high-performance observability data pipeline that can collect from many sources and route to many sinks, including Loki:
|
|
Fluent Bit
|
|
Direct HTTP Push
Any application can push logs directly to Loki’s HTTP API:
|
|
This is useful for scripts, one-off events, or applications that cannot run a sidecar agent.
Part 7: LogQL Fundamentals
LogQL is Loki’s query language. It has two types of queries: log queries (return log lines) and metric queries (compute aggregate values from log streams, returning time series). The syntax is intentionally similar to PromQL to reduce the learning curve for Prometheus users.
Log Stream Selectors
Every LogQL query begins with a stream selector — a set of label matchers in curly braces:
{job="nginx"}
{job="nginx", host="web01"}
{job=~"nginx|traefik"} # Regex match
{job!="auth"} # Negative exact match
{container=~"app-.*", env="prod"} # Regex on one label, exact on another
The stream selector must match at least one label. You cannot query Loki without a stream selector (unlike Elasticsearch where you can search everything).
Log Pipeline Operators
After the stream selector, you pipe the log lines through filter expressions:
# Contains string (case-sensitive)
{job="app"} |= "ERROR"
# Does not contain string
{job="app"} != "healthcheck"
# Regex match
{job="auth"} |~ "Failed (password|publickey)"
# Regex not match
{job="nginx"} !~ "^(10\.|192\.168\.)"
Multiple filters chain with implicit AND:
{job="app"} |= "ERROR" != "connection refused" |~ "user_id=\d+"
Parser Expressions
Parsers extract structured fields from log lines, making them available for further filtering and metric queries:
# Parse entire line as JSON
{job="app"} | json
# Parse specific JSON fields with aliases
{job="app"} | json msg="message", lvl="level", ts="timestamp"
# Parse key=value (logfmt) format
{job="app"} | logfmt
# Named capture groups from regex
{job="nginx"} | regexp `(?P<method>\w+) (?P<path>\S+) HTTP/\d\.\d" (?P<status>\d+)`
# Loki pattern parser (simpler syntax for fixed-format logs)
{job="nginx"} | pattern `<ip> - - [<_>] "<method> <path> <_>" <status> <bytes>`
After parsing, use | label_filter to filter on extracted fields:
# HTTP 5xx errors from nginx (status is a string after json parse)
{job="nginx"} | json | status >= 500
# Slow requests over 2 seconds
{job="nginx"} | json | response_time > 2.0
# Specific user
{job="app"} | json | user_id = "12345"
Line Format
Reformat the output line using extracted fields — useful in dashboards:
{job="nginx"} | json | line_format "{{.method}} {{.path}} → {{.status}} ({{.response_time}}s)"
Metric Queries
Metric queries wrap log queries in aggregation functions to produce time series:
# Log lines per second over a 5-minute rolling window
rate({job="nginx"}[5m])
# Total log volume
count_over_time({job="app"}[1h])
# Bytes ingested per second
bytes_rate({job="app"}[5m])
# Sum across label dimensions (like PromQL's sum by())
sum by (status) (rate({job="nginx"} | json [5m]))
Practical LogQL Examples
All errors in the last hour:
{job="app"} |= "ERROR"
Error rate as a metric (errors per second):
sum(rate({job="app"} |= "ERROR" [5m]))
SSH brute force detection — failed password attempts per 5 minutes per source IP:
sum by (source_ip) (
count_over_time(
{job="auth"} |~ "Failed password" | regexp `from (?P<source_ip>\d+\.\d+\.\d+\.\d+)` [5m]
)
)
Nginx 5xx error rate:
sum(rate({job="nginx"} | json | status >= 500 [5m])) by (status)
Top paths by request count (for a rate table in Grafana):
topk(10, sum by (path) (
count_over_time({job="nginx"} | json [5m])
))
Slow request analysis — 99th percentile response time:
quantile_over_time(0.99,
{job="nginx"}
| json
| unwrap response_time [5m]
) by (path)
Container logs filtered by container name:
{container="myapp"} |= "panic"
All logs from a Kubernetes namespace:
{namespace="production"} |= "error"
OOM killer events on any host:
{job="syslog"} |~ "Out of memory|oom_kill_process|Killed process"
Recent auth events for a specific user:
{job="auth"} |~ "user admin" | line_format "{{__line__}}"
Log volume by job — useful for identifying noisy sources:
sum by (job) (bytes_rate({host="web01"}[5m]))
logcli — Querying Loki from the Terminal
logcli is the official command-line client for Loki. Install it on any machine that needs terminal access to your log data:
|
|
Part 8: Grafana Integration
Adding Loki as a Datasource
If you used the provisioning file from Part 3, Loki is already configured. To add it manually:
- In Grafana, go to Connections → Data Sources → Add data source
- Select Loki
- Set URL to
http://loki:3100(or your Loki address) - Click Save & Test — you should see “Data source connected and labels found”
Explore View
The Explore view is your primary interface for ad-hoc log investigation. Select the Loki datasource and use the query builder or switch to raw LogQL mode.
Key features in Explore:
- Live tailing — click the “Live” button to stream logs in real time, like
tail -fin a browser - Derived fields — clickable links extracted from log content (trace IDs, request IDs)
- Time range picker — zoom into any window to investigate an incident
- Log context — click any log line to see lines immediately before and after it
Dashboard Panels for Logs
Logs panel — Displays a scrollable, filterable list of log lines. Use it to surface the most important log streams on an operational dashboard. In panel JSON:
|
|
Stat panel with log metrics — Use a LogQL metric query to drive a stat or time series panel. For example, total error count in the last hour:
sum(count_over_time({job="app"} |= "ERROR" [1h]))
Dashboard Variables with Label Values
Use Loki label values as dashboard template variables to create filterable dashboards:
- In dashboard settings, add a variable:
- Type: Query
- Data source: Loki
- Query:
label_values(job)— populates the list from all availablejoblabel values
- Reference the variable in your queries:
{job=~"$job"} |= "ERROR"
Correlating Logs and Metrics in the Same Dashboard
This is where the LGTM stack shines. A well-constructed dashboard has:
- Row 1: Prometheus metrics — request rate, error rate, latency (time series panels)
- Row 2: Loki log metrics — error count, log volume by level (stat + bar chart panels)
- Row 3: Loki logs panel — the actual log lines for the selected service and time range
When an alert fires on the Prometheus error rate, you scroll down to the logs panel — same time range, same service — and the relevant log lines are right there. No context switching, no SSH.
Time range synchronization: All panels in a Grafana dashboard share the global time range by default. When you zoom into the spike on the error rate graph, the logs panel zooms to the same window automatically.
Derived Fields — Trace ID Links to Tempo
If your application logs include a trace ID, you can configure Loki to turn that ID into a clickable link to Tempo:
In the Loki datasource settings, under Derived Fields:
Name: TraceID
Regex: "trace_id":"(\w+)"
Query: ${__value.raw}
URL: (select your Tempo datasource)
Now when a log line contains "trace_id":"abc123", the trace ID is highlighted and clicking it opens the corresponding trace in Tempo. This is the logs-to-traces correlation that makes the full LGTM stack valuable.
Part 9: Alerting with Loki Ruler
Loki’s Ruler component evaluates LogQL expressions on a schedule and fires alerts to Alertmanager — the exact same Alertmanager you use for Prometheus alerts.
Ruler Configuration
The ruler section is already included in the loki-config.yaml from Part 3. Create an alert rules directory:
|
|
The fake subdirectory name corresponds to the tenant ID (auth_enabled: false uses the tenant fake).
Alert Rules File
Create /path/to/loki-stack/loki-rules/fake/alert-rules.yaml:
|
|
Mount this directory into your Loki container by adding to the volumes in docker-compose.yml:
|
|
Verify that Loki is loading your rules:
|
|
Alerts flow from Loki Ruler → Alertmanager → your notification channels (Slack, PagerDuty, email) — the same path as Prometheus alerts. If you have Alertmanager already configured for Prometheus, your routing rules and receivers apply to Loki alerts too.
Part 10: Label Strategy and Performance
Label design has a bigger impact on Loki’s performance and cost than almost any other configuration decision.
High Cardinality: The Core Problem
Each unique combination of label values creates a new log stream, which requires its own index entry and chunk storage. If you put a high-cardinality value like a user ID or request ID into a label, you will create millions of tiny streams — each with a handful of log lines. Loki performs very poorly in this situation because it must open and scan enormous numbers of tiny chunks for every query.
Good labels (low cardinality — a handful of distinct values):
host— server hostname (a dozen servers = a dozen values)job— service or component namecontainer— container nameenv—prod,staging,devlevel—info,warn,error,debugnamespace— Kubernetes namespacestatus— HTTP status code class (200, 404, 500)
Bad labels (high cardinality — unbounded unique values):
user_id— potentially millions of distinct valuesrequest_id/trace_id— unique per request, never repeatip_address— unboundedsession_id— unique per sessiontimestampas a label — each nanosecond is a new value
Put high-cardinality values in the log line content, not in labels. Extract them at query time with | json, | regexp, or | pattern.
limits_config Tuning
|
|
Retention Per Stream
You can set different retention periods for different streams using per-tenant overrides or by configuring the compactor with stream selectors. For homelab, global retention is usually sufficient. For production, you might want:
- Security/auth logs: 90 days
- Debug logs: 7 days
- Application error logs: 30 days
Index and Cache Performance
For a single-node deployment with moderate volume, the embedded cache in query_range.results_cache is sufficient. For higher query loads, external Memcached significantly reduces query latency for repeated time range queries:
|
|
Stream Sharding for High-Volume Sources
If a single log source generates more than ~5MB/s, a single stream becomes a bottleneck because Loki serializes writes to each stream. Add an artificial sharding label to distribute the load:
In Promtail’s relabel config, add a label that cycles through a small set of values:
|
|
Part 11: Practical Homelab Use Cases
Centralizing Logs from Multiple Docker Hosts
Run Promtail on each host, shipping to a central Loki instance. Add a host label with the machine’s hostname:
|
|
Query all hosts at once:
{job="nginx"} |= "ERROR"
Or filter to a specific host:
{job="nginx", host="myserver01"} |= "ERROR"
Auth Log Monitoring for Intrusion Detection
A Grafana dashboard row showing:
- Stat panel — failed SSH attempts in last 24h:
sum(count_over_time({job="auth"} |= "Failed password" [24h])) - Time series — failed attempts over time:
sum(rate({job="auth"} |= "Failed password" [5m])) - Logs panel — raw auth log lines:
{job="auth"} |~ "Failed|Invalid|error" - Table — top attacking IPs (using LogQL + regex to extract IP, then topk)
K3s / Kubernetes Pod Logs
On each K3s node, run Promtail or Alloy targeting /var/log/pods. Labels are extracted from the pod log path:
# All logs from the production namespace
{namespace="production"}
# Logs from a specific pod
{namespace="production", pod_name=~"myapp-.*"}
# Errors from all containers in a deployment
{namespace="production", container_name="myapp"} |= "error"
The DaemonSet Helm chart for Promtail handles all of this automatically on Kubernetes:
|
|
Nginx/Traefik Access Log Analysis
With nginx logs parsed and a status label extracted:
# Error rate by status code
sum by (status) (rate({job="nginx"} | json [5m]))
# Top 404 paths (content that doesn't exist)
topk(10, sum by (path) (
count_over_time({job="nginx"} | json | status = 404 [1h])
))
# Bandwidth by path
sum by (path) (
bytes_over_time({job="nginx"} | json [1h])
)
Systemd Journal Forwarding
Promtail can read the systemd journal directly without needing log files:
|
|
This captures everything journald sees — kernel messages, systemd unit output, any application using the journal API — without needing any log files on disk.
Putting It All Together
The Loki stack gives you the third leg of the observability triangle alongside Prometheus metrics and (optionally) Tempo traces. The value compounds as you add more sources: once you have a Grafana dashboard with CPU/memory graphs from Prometheus and log panels from Loki both responding to the same dashboard variables, incident investigation becomes qualitatively different. You stop jumping between terminals and start reasoning about systems from a single view.
Start with the Docker Compose stack from Part 3, get your Docker container logs flowing, then layer in system logs and alerting rules. Once you have those working and you understand what your log volume actually looks like, tune your label strategy and retention settings. The default configuration will handle years of homelab operation without changes.
For the full picture, add Tempo for distributed traces and wire up the derived fields so trace IDs in your logs link directly to trace waterfall views. At that point you have the complete LGTM stack and the investigative capability that previously required enterprise tooling and a dedicated SRE team — running at home on hardware you already own.
Quick Reference
Essential LogQL Patterns
# All errors, any service
{job=~".+"} |= "ERROR" | json
# Error rate for alerting
sum by (job) (rate({job=~".+"} |= "ERROR" [5m]))
# SSH failures in last 10 minutes
{job="auth"} |= "Failed password" | regexp `from (?P<ip>\S+) port`
# Nginx 5xx last hour
{job="nginx"} | json | status >= 500
# Container logs by name
{container="traefik"} |= "level=error"
# OOM events
{job="syslog"} |~ "oom_kill|Out of memory"
# K8s pod logs
{namespace="prod", pod_name=~"api-.*"} | json | level = "error"
Loki API Endpoints
| Endpoint | Purpose |
|---|---|
GET /ready |
Health check |
GET /metrics |
Prometheus metrics about Loki itself |
GET /loki/api/v1/labels |
List all label names |
GET /loki/api/v1/label/{name}/values |
List values for a label |
GET /loki/api/v1/query_range |
Execute a LogQL query over a range |
POST /loki/api/v1/push |
Push log entries |
GET /loki/api/v1/rules |
List alerting rules |
logcli Quick Reference
|
|
Comments