Loki Logging - Centralized Log Shipping and LogQL
Status: Active
Last Updated: 2026-08-26
Category: Observability - Logs
Prerequisites: prometheus-basics, grafana-dashboards, docker-compose-intro
Time: 2-3 hours
Tags: loki, promtail, alloy, logql, logging, grafana, docker
Summary
Loki stores logs as label-indexed streams — "Prometheus, but for logs" — and queries them with LogQL from Grafana. This article deploys Loki plus a log agent (Alloy or Promtail) to ship Docker container and system logs, wires the Grafana datasource, covers the LogQL patterns you'll use daily, and configures retention so the disk doesn't fill.
What You'll Learn
By the end of this article, you'll be able to:
- Deploy Loki and an agent (Alloy or Promtail) in Docker Compose
- Ship Docker JSON logs plus journald/syslog into Loki
- Add Loki as a Grafana datasource and run LogQL queries
- Extract fields from unstructured lines with pipeline stages
- Configure retention and compaction for predictable disk usage
Table of Contents
- Context / Why This Matters
- Implementation / Core Content
- Practical Examples
- Common Pitfalls & Troubleshooting
- Next Steps / Ops Actions
Context / Why This Matters
metrics-vs-logs.md established that logs answer "what exactly happened?" while metrics answer "is it healthy?". Until now your logs have lived in docker logs on each host — fine for one box, useless when a problem spans three machines or survives a docker rm. Loki gives you one queryable place for all of it, reusing the Grafana instance you already run (grafana-dashboards.md) and the same label mental model as Prometheus (prometheus-basics.md).
Key architectural difference from Elasticsearch: Loki indexes only labels, not line content. Storage is cheap (compressed chunks in object store or local disk); querying relies on time + label selection first, then filtering text. That's why low-cardinality labels and structured logs matter even more here than in Prometheus.
Implementation / Core Content
Architecture in one diagram
containers → docker json log files ─┐
journald / syslog ──────────────────┼→ Alloy/Promtail (agent) → HTTP push → Loki → Grafana
app files (/var/log/...) ───────────┘
The agent tails files locally, applies pipelines (parse/drop/relabel), and pushes batches to Loki's /loki/api/v1/push endpoint.
Docker Compose deployment
services:
loki:
image: grafana/loki:3.1.1
container_name: loki
restart: unless-stopped
ports:
- "127.0.0.1:3100:3100"
volumes:
- ./loki-config.yml:/etc/loki/config.yml:ro
- loki-data:/loki
command: -config.file=/etc/loki/config.yml
alloy:
image: grafana/alloy:v1.2.1
container_name: alloy
restart: unless-stopped
user: "0" # needs root to read docker logs
volumes:
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/log:/var/log:ro
- ./alloy-config.alloy:/etc/alloy/config.alloy:ro
command: run --server.http.listen-addr=0.0.0.0:12345 /etc/alloy/config.alloy
volumes:
loki-data:
Prefer the legacy agent? Swap the alloy service for:
promtail:
image: grafana/promtail:3.1.1
container_name: promtail
restart: unless-stopped
volumes:
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/log:/var/log:ro
- ./promtail-config.yml:/etc/promtail/config.yml:ro
command: -config.file=/etc/promtail/config.yml
Promtail is in LTS-only maintenance; new deployments should use Alloy. The concepts below map one-to-one (pipeline stages ≈ processing stages), and both are covered.
Loki server config (loki-config.yml)
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory # single-node only
schema_config:
configs:
- from: 2024-01-01
store: tsdb # tsdb is the current default engine
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 720h # 30 days
reject_old_samples: true
reject_old_samples_max_age: 168h
max_query_series: 500
compactor:
working_directory: /loki/compactor
retention_enabled: true # REQUIRED or retention_period is ignored
The classic gotcha is right there in the comment: setting retention_period without compactor.retention_enabled: true silently keeps data forever.
Agent config — Alloy river syntax (alloy-config.alloy)
// Discover all running containers
discovery.docker "linux" {
host = "unix:///var/run/docker.sock"
refresh_interval = "5s"
}
discovery.relabel "docker" {
targets = discovery.docker.linux.targets
// label = container name
rule {
source_labels = ["__meta_docker_container_name"]
regex = "/(.*)"
target_label = "container"
}
// label = compose project
rule {
source_labels = ["__meta_docker_container_label_com_docker_compose_project"]
target_label = "compose_project"
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.relabel.docker.output
forward_to = [loki.process.docker.receiver]
labels = { job = "docker" }
}
// Parse the docker json wrapper so level/message are searchable
loki.process "docker" {
stage.json {
expressions = { level = "log", stream = "stream", time = "time" }
}
stage.labels {
values = { stream = "" } // promote stdout/stderr to a label
}
// keep ERROR/WARN lines longer by tagging them; drop chatty DEBUG entirely
stage.match {
selector = "{job=\"docker\"}"
stages {
stage.drop {
expression = "\"level\":\"DEBUG\""
}
}
}
forward_to = [loki.write.default.receiver]
}
// Also ship kernel/auth logs via journal
loki.source.journal "host" {
max_age = "12h"
labels = { job = "journal", host = env("HOSTNAME") }
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint { url = "http://loki:3100/loki/api/v1/push" }
}
Equivalent Promtail snippet for the same docker scrape (scrape_configs section):
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: [__meta_docker_container_name]
regex: "/(.*)"
target_label: container
- source_labels: [__meta_docker_container_label_com_docker_compose_project]
target_label: compose_project
pipeline_stages:
- json:
expressions:
level: log
stream: stream
- labels:
stream:
- drop:
expression: '"level":"DEBUG"'
Grafana datasource
Add via UI (Connections → Data sources → Loki → URL http://loki:3100) or provision:
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: false
Verify with Explore → pick Loki → query {job="docker"} — you should see live tailing output within seconds.
LogQL essentials
LogQL has two layers: a log selector (labels, like PromQL selectors) then optional filters/parsing:
# All lines from one container
{container="nginx-proxy"}
# Label match + line contains
{job="docker", container="api"} |= "error"
# Chained filters: contains but not this
{job="docker"} |= "error" != "healthcheck"
# Regex filter (case-insensitive)
{job="docker"} |~ "(?i)(fatal|panic)"
# Parse JSON and filter/return parsed fields
{container="api"} | json | level="ERROR" | line_format "{{.time}} {{.message}}"
# Count matches over time — logs become metrics!
sum(rate({job="docker"} |= "error" [5m])) by (container)
# Top offenders: error count per container in the last hour
topk(5, sum(count_over_time({job="docker"} |= "error" [1h])) by (container))
That last family of expressions is the bridge back to metrics: you can graph and alert on log-derived rates directly in Grafana panels, no Prometheus required.
Practical Examples
Example 1: Investigate a 500-spike end to end
1. Metrics panel shows nginx 5xx rate climbing at 14:02.
2. Explore → Loki: {container="nginx-proxy"} |~ " 5[0-9][0-9] "
→ upstream timed out while reading response header
3. Pivot to the app: {container="api"} |= "timeout" | json | line_format "{{.msg}}"
→ waiting on postgres pool, pool_size=10 exhausted
4. Fix known issue / restart; confirm error rate returns to baseline.
Total time: minutes, across two hosts' logs, zero SSH.
Example 2: Log-derived alert panel
Grafana alert (or Loki ruler) on errors appearing in any production container:
sum by (container) (count_over_time({job="docker", compose_project="prod"} |= "FATAL" [5m])) > 0
Example 3: Verify retention actually deletes
# Check oldest chunk timestamp still present after a week:
curl -s 'http://localhost:3100/loki/api/v1/query_range' \
--data-urlencode 'query=sum(bytes_over_time({job="docker"}[24h]))' \
--data-urlencode "start=$(date -d '35 days ago' +%s)000000000" \
--data-urlencode "end=$(date -u +%s)000000000" | jq .
# Empty result beyond ~30 days == compaction working
Common Pitfalls & Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
Loki ignores retention_period, disk grows forever |
compactor.retention_enabled not set |
Add it under compactor: and restart |
| "entry out of order" rejected | Two agents shipping same file, or clock skew behind NAT | Run exactly one agent per source; NTP-sync hosts |
| Too many streams error / Loki OOMs | High-cardinality labels (request ID, trace ID as labels) | Keep labels to container/compose_project/host/stream; put IDs in the line |
| No docker logs visible | Alloy lacks socket/container dir mounts, or not root | Mount /var/run/docker.sock and /var/lib/docker/containers; user: "0" |
| Duplicate log lines | Same file scraped by both journal and file sources | Pick one source per log stream |
| Queries slow on big time ranges | Scanning months of chunks | Narrow the time range first; add label selectors before line filters |
| DEBUG noise fills storage | App log level too verbose | Set level in app config, or add stage.drop in the pipeline |
Next Steps / Ops Actions
- Correlate traces with log lines using shared IDs: jaeger-tracing
- Alert on log-derived rates alongside metric alerts: simple-alerts
- Route those alerts properly: alertmanager-config
- Back up the
loki-datavolume with the rest of your infra: restic-backups
Sources & Related Articles
External references consulted:
- https://grafana.com/docs/loki/latest/
- https://grafana.com/docs/alloy/latest/
- https://grafana.com/docs/loki/latest/logql/log_queries/
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation by kb-writing session (ox-alpha).