Grafana Loki with Docker Compose: Centralized Log Aggregation Guide (2026)

how to set up grafana loki with docker compose 2026

Table of Contents

  1. What is Grafana Loki and Why It Matters in 2026
  2. How Loki Works: Labels, Not Full-Text Index
  3. Important: Promtail is EOL — Use Grafana Alloy Instead
  4. Loki vs ELK Stack: When to Use Which
  5. Architecture Overview
  6. Prerequisites
  7. Project Structure
  8. Step 1: Loki Configuration
  9. Step 2: Grafana Alloy Configuration
  10. Step 3: The Full Docker Compose Stack
  11. Step 4: Start the Stack and Verify
  12. Step 5: Add Loki as a Data Source in Grafana
  13. Step 6: Writing LogQL Queries
  14. Step 7: Set Up Log-Based Alerts
  15. Integrate with Existing Prometheus + Grafana Stack
  16. Collecting Docker Container Logs
  17. Label Design Best Practices
  18. Common Issues and Quick Fixes
  19. Next Steps

When an incident happens at 2 AM, the first thing every engineer does is reach for the logs. Scattered log files across /var/log/nginx/, /var/log/syslog, and Docker container logs are difficult to correlate during incident response. Grafana Loki solves this by centralizing all logs into a single queryable interface — and because it’s designed alongside Prometheus, it integrates directly into the same Grafana dashboard as your metrics. One screen for both.

Loki has seen roughly 80% year-over-year growth in adoption through 2025 and into 2026, driven primarily by teams already running Prometheus and Grafana who want a unified observability stack without adding a second complex technology.

What is Grafana Loki and Why It Matters in 2026

Grafana Loki is a horizontally scalable, highly available, multi-tenant log aggregation system inspired by Prometheus. Unlike traditional log management solutions, Loki indexes only a small set of labels (metadata) for each log stream rather than the full text of every log line. The log data itself is compressed and stored in object storage or the local filesystem.

This single design decision — index labels, not log content — is what makes Loki dramatically cheaper to operate than Elasticsearch-based stacks.

How Loki Works: Labels, Not Full-Text Index

Traditional logging systems (Elasticsearch, Splunk) index every word of every log line into a search index. This makes search fast but extremely expensive to store and operate.

Loki takes a different approach:

Traditional (ELK):
  Log line → tokenize every word → full-text index → search
  Storage: large index + log data
  Cost: high (index = 30-50% of raw data size)

Loki approach:
  Log line → attach labels → compress + store chunks → label index only
  Storage: tiny index + compressed chunks
  Cost: 10x-100x cheaper than Elasticsearch

Loki stores logs as compressed chunks in object storage or the local filesystem, indexes only stream labels, and uses LogQL (a query language inspired by PromQL) to filter and aggregate logs at query time rather than index time.

How a log stream works:

Application logs →  labels: {job="nginx", env="prod", host="server1"}
                    log data: "2026-07-15 08:32:11 GET /api/health 200 12ms"
                              "2026-07-15 08:32:12 GET /api/users 500 45ms"
                              ...

Loki stores all log lines with the same label set as a single stream. You query streams using label selectors, then optionally filter the log content within those streams.

Important: Promtail is EOL — Use Grafana Alloy Instead

Promtail reached end-of-life on March 2, 2026. Use Grafana Alloy or another supported client for new production deployments.

If you’ve seen older Loki tutorials using Promtail as the log shipper, those are now outdated. Grafana Alloy is the supported replacement — it’s more powerful (supports metrics, logs, traces, and profiles in one agent), uses a cleaner configuration syntax, and is actively maintained.

Migration from Promtail to Alloy is straightforward — Alloy can read Promtail configuration files directly as a migration path, but new deployments should use native Alloy configuration.

Supported log shippers in 2026:

AgentStatusBest for
Grafana Alloy✅ Active (recommended)New deployments — unified observability agent
Promtail❌ EOL March 2026Legacy — do not use for new setups
Fluent Bit✅ ActiveKubernetes, resource-constrained environments
OpenTelemetry Collector✅ ActiveMulti-vendor observability pipelines
Docker Loki Plugin✅ ActiveSimple Docker container log forwarding

Loki vs ELK Stack: When to Use Which

Grafana LokiELK Stack (Elasticsearch)
Storage costVery low (10x-100x cheaper)High (full-text index overhead)
RAM requirement200-400MB (single node)4GB+ minimum
Setup complexityLowHigh
Full-text search❌ No (label + filter only)✅ Yes
Query languageLogQL (similar to PromQL)Lucene/KQL
Grafana integration✅ NativeVia plugin
Prometheus integration✅ Native (same label model)Via Metricbeat
Best forDevOps teams, Docker/K8s, Prometheus usersEnterprise search, compliance, complex analytics

Use Loki when: you’re already running Prometheus + Grafana, you want logs and metrics in the same dashboard, or you need cost-effective log storage for high-volume Docker environments.

Use Elasticsearch when: you need full-text search across unstructured logs, complex analytics, or regulatory compliance requiring detailed log indexing.

Architecture Overview

Docker containers / Host logs / Applications
        │
        │ log lines
        ▼
Grafana Alloy (log collector)
  ├── Tails /var/log/* files
  ├── Reads Docker container logs
  └── Attaches labels → pushes to Loki
        │
        │ HTTP push (port 3100)
        ▼
Loki (log storage + query engine)
  ├── Validates labels
  ├── Compresses and stores chunks
  └── Serves LogQL queries
        │
        │ LogQL queries
        ▼
Grafana (visualization + alerts)
  ├── Explore → real-time log search
  ├── Dashboard panels with log panels
  └── Alert rules → Alertmanager

Prerequisites

Project Structure

loki-stack/
├── compose.yml
├── .env
├── loki/
│   └── loki-config.yaml
└── alloy/
    └── config.alloy
mkdir -p ~/loki-stack/{loki,alloy}
cd ~/loki-stack

Step 1: Loki Configuration

# loki/loki-config.yaml
auth_enabled: false    # Set to true for multi-tenant deployments

server:
  http_listen_port: 3100
  grpc_listen_port: 9096
  log_level: warn

common:
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb             # TSDB index (recommended since Loki 2.8)
      object_store: filesystem
      schema: v13
      index:
        prefix: loki_index_
        period: 24h

limits_config:
  # Retention: delete logs older than 30 days
  retention_period: 720h      # 30 days
  # Max log line size
  max_line_size: 256KB
  # Reject high-cardinality label sets
  max_label_names_per_series: 15
  max_label_value_length: 2048
  # Query limits
  max_entries_limit_per_query: 5000

compactor:
  working_directory: /loki/compactor
  retention_enabled: true
  retention_delete_delay: 2h

query_range:
  results_cache:
    cache:
      embedded_cache:
        enabled: true
        max_size_mb: 100

Key configuration choices explained:

  • auth_enabled: false — single-tenant mode, appropriate for most self-hosted setups. Enable for multi-team deployments where log isolation is required.
  • store: tsdb — the modern index format since Loki 2.8, significantly more efficient than the legacy boltdb-shipper.
  • retention_period: 720h — 30 days of log retention. Adjust based on your storage capacity. The compactor runs deletion automatically.

Step 2: Grafana Alloy Configuration

Grafana Alloy uses a configuration language called “River” (.alloy files). This config collects logs from Docker containers and host system logs:

// alloy/config.alloy

// ========================================
// LOKI WRITE ENDPOINT
// ========================================
loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}

// ========================================
// DOCKER CONTAINER LOGS
// ========================================
discovery.docker "containers" {
  host = "unix:///var/run/docker.sock"
  refresh_interval = "10s"
}

// Relabel Docker metadata into Loki labels
discovery.relabel "docker_labels" {
  targets = discovery.docker.containers.targets

  // Use container name as job label
  rule {
    source_labels = ["__meta_docker_container_name"]
    regex         = "/(.*)"
    target_label  = "container"
  }

  // Use compose service name if available
  rule {
    source_labels = ["__meta_docker_container_label_com_docker_compose_service"]
    target_label  = "service"
  }

  // Keep the image name for context
  rule {
    source_labels = ["__meta_docker_container_image"]
    target_label  = "image"
  }
}

// Scrape Docker container logs
loki.source.docker "docker_logs" {
  host       = "unix:///var/run/docker.sock"
  targets    = discovery.relabel.docker_labels.output
  forward_to = [loki.write.default.receiver]
}

// ========================================
// HOST SYSTEM LOGS
// ========================================

// Syslog
loki.source.file "syslog" {
  targets = [{
    __path__ = "/var/log/syslog",
    job       = "syslog",
    host      = env("HOSTNAME"),
  }]
  forward_to = [loki.write.default.receiver]
}

// Auth log (SSH logins, sudo usage)
loki.source.file "auth_log" {
  targets = [{
    __path__ = "/var/log/auth.log",
    job       = "auth",
    host      = env("HOSTNAME"),
  }]
  forward_to = [loki.write.default.receiver]
}

// UFW firewall logs
loki.source.file "ufw_log" {
  targets = [{
    __path__ = "/var/log/ufw.log",
    job       = "ufw",
    host      = env("HOSTNAME"),
  }]
  forward_to = [loki.write.default.receiver]
}

// ========================================
// NGINX LOGS (if Nginx is running on host)
// ========================================
loki.source.file "nginx_access" {
  targets = [{
    __path__ = "/var/log/nginx/access.log",
    job       = "nginx",
    log_type  = "access",
    host      = env("HOSTNAME"),
  }]
  forward_to = [loki.write.default.receiver]
}

loki.source.file "nginx_error" {
  targets = [{
    __path__ = "/var/log/nginx/error.log",
    job       = "nginx",
    log_type  = "error",
    host      = env("HOSTNAME"),
  }]
  forward_to = [loki.write.default.receiver]
}

Step 3: The Full Docker Compose Stack

.env file:

GRAFANA_ADMIN_PASSWORD=change_this_strong_password
TZ=Asia/Jakarta
# compose.yml
version: '3.8'

networks:
  loki-net:
    driver: bridge

volumes:
  loki-data:
  grafana-data:

services:
  loki:
    image: grafana/loki:3.7.0
    container_name: loki
    restart: unless-stopped
    command: -config.file=/etc/loki/loki-config.yaml
    volumes:
      - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro
      - loki-data:/loki
    ports:
      - "127.0.0.1:3100:3100"    # Bind to localhost only
    networks:
      - loki-net
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  alloy:
    image: grafana/alloy:v1.16.3
    container_name: alloy
    restart: unless-stopped
    command: run /etc/alloy/config.alloy --server.http.listen-addr=0.0.0.0:12345
    volumes:
      - ./alloy/config.alloy:/etc/alloy/config.alloy:ro
      - /var/log:/var/log:ro                                # Host system logs
      - /var/run/docker.sock:/var/run/docker.sock:ro        # Docker container logs
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    environment:
      HOSTNAME: ${HOSTNAME:-localhost}
    depends_on:
      loki:
        condition: service_healthy
    networks:
      - loki-net

  grafana:
    image: grafana/grafana:11.x.x
    container_name: grafana-loki
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
      GF_USERS_ALLOW_SIGN_UP: "false"
      TZ: ${TZ}
    volumes:
      - grafana-data:/var/lib/grafana
    ports:
      - "3000:3000"
    networks:
      - loki-net
    depends_on:
      loki:
        condition: service_healthy

Note on Grafana: If you already have Grafana running as part of your Prometheus + Grafana monitoring stack, skip the Grafana service here and instead add Loki as a second data source to your existing Grafana instance. One Grafana for everything — that’s the whole point of the unified stack.

Step 4: Start the Stack and Verify

cd ~/loki-stack
docker compose up -d
docker compose logs -f loki

Wait for Loki ready in the logs, then verify each service:

# Loki ready check
curl -s http://localhost:3100/ready
# Expected: ready

# Check Loki labels (should populate after Alloy starts shipping logs)
curl -s http://localhost:3100/loki/api/v1/labels | jq .

# Check Alloy is running
docker compose logs alloy | tail -20

Step 5: Add Loki as a Data Source in Grafana

  1. Open Grafana at http://<server-ip>:3000
  2. Go to Connections → Data Sources → Add data source
  3. Select Loki
  4. Set URL: http://loki:3100

Critical: Use the Docker service name loki as the hostname, not localhost.When Grafana and Loki run as separate Docker containers, localhost inside the Grafana container refers to the Grafana container itself, not the host machine or the Loki container.This is the most common misconfiguration for new users.

  1. Click Save & Test — you should see “Data source connected and labels found”

Step 6: Writing LogQL Queries

LogQL uses stream selectors in curly braces to filter log streams — for example {job="nginx", env="prod"} — then optionally adds pipe expressions for filtering, parsing, and metric queries.

Basic log stream queries:

# All logs from a specific job
{job="nginx"}

# All Docker container logs
{job="docker"}

# Specific container by name
{container="redis"}

# All logs containing "error" (case-sensitive)
{job="nginx"} |= "error"

# Case-insensitive search
{job="nginx"} |~ "(?i)error"

# Exclude patterns (remove health check noise)
{job="nginx"} != "GET /health"

# Multiple label selectors
{job="nginx", host="server1"}

Parse structured logs:

# Parse JSON logs and filter by field
{container="myapp"} | json | level="error"

# Parse logfmt format
{job="auth"} | logfmt | action="Failed password"

# Extract fields with regex
{job="nginx"} | regexp `(?P<method>\w+) (?P<path>\S+) HTTP/\d\.\d" (?P<status>\d+)`
| status = "500"

Practical queries for your infrastructure:

# SSH failed login attempts
{job="auth"} |= "Failed password"

# UFW firewall blocks
{job="ufw"} |= "UFW BLOCK"

# HTTP 5xx errors from Nginx
{job="nginx", log_type="access"} |= '" 5'

# Docker container crashes (OOM kills)
{job="syslog"} |= "Killed process"

# Redis errors from Docker containers
{container="redis"} |= "ERROR"

# MongoDB connection issues
{container="mongodb"} |= "connection refused"

Metric queries (LogQL → time series):

# Error rate per minute (for Grafana graph panel)
rate({job="nginx"} |= "error" [1m])

# Count of 500 errors over time
count_over_time({job="nginx"} |= '" 500' [5m])

# Top containers by log volume
topk(5, sum by (container) (rate({job="docker"}[5m])))

# SSH login attempts per minute
rate({job="auth"} |= "Failed password" [1m])

Step 7: Set Up Log-Based Alerts

In Grafana: Alerting → Alert Rules → New alert rule

Alert: High error rate in Nginx:

# Alert expression — fires when more than 10 errors/min
sum(rate({job="nginx"} |= "error" [5m])) > 0.17

Configuration:

  • Name: High Nginx Error Rate
  • Condition: above 0.17 (= 10 errors per minute)
  • Pending period: 2m (must persist for 2 minutes before firing)
  • Contact point: Slack, email, or webhook

Alert: SSH brute force detection:

sum(count_over_time({job="auth"} |= "Failed password" [5m])) > 20

Fires when more than 20 failed SSH login attempts occur in 5 minutes — a common brute force pattern.

Alert: Container crash loop:

count_over_time({job="syslog"} |= "Killed process" [10m]) > 3

Integrate with Existing Prometheus + Grafana Stack

If you already have the Prometheus + Grafana monitoring stack running, integrate Loki into it rather than running a separate Grafana:

1. Connect Loki to the existing monitoring network:

Add to your Loki compose.yml:

networks:
  loki-net:
    driver: bridge
  monitoring:
    external: true    # Join the existing Prometheus+Grafana network

Add monitoring network to the loki service:

services:
  loki:
    networks:
      - loki-net
      - monitoring    # Reachable from existing Grafana

2. Add Loki as a data source in your existing Grafana:

  • Go to Connections → Data Sources → Add → Loki
  • URL: http://loki:3100
  • Save & Test

3. Create a unified dashboard with both Prometheus metrics and Loki logs side by side:

In any Grafana dashboard panel: set data source to Loki and use a Logs panel type. Place it next to your existing Prometheus metric graphs. When you see a CPU spike at 14:32, you can immediately look at the log panel for the same time range and see what was happening.

4. Add Alloy to scrape Prometheus metrics too:

Alloy can collect both logs (for Loki) AND metrics (for Prometheus) in a single agent:

// Add to config.alloy — scrape Prometheus metrics too
prometheus.scrape "node" {
  targets = [{"__address__" = "node-exporter:9100"}]
  forward_to = [prometheus.remote_write.mimir.receiver]
}

Collecting Docker Container Logs

The Alloy configuration above auto-discovers Docker containers via the Docker socket. Every container’s logs are immediately available in Grafana under the container label.

For services like Redis and MongoDB from our earlier guides:

# Redis logs — monitor maxmemory warnings
{container="redis"} |= "WARNING"

# MongoDB replica set status changes
{container="mongodb"} |= "PRIMARY"

# Any OOM (out-of-memory) kills affecting containers
{job="syslog"} |= "oom-kill"

This pairs directly with the metrics from your Prometheus + Grafana monitoring stack — if you see a Redis memory metric spike, switch to the Loki panel for the same timestamp and see the exact warning messages that preceded it.

Label Design Best Practices

Labels are the most important design decision in a Loki deployment. Poor label design leads to high cardinality (too many streams) which degrades performance.

Good label candidates: app, env (production/staging), host, namespace, pod, level (info/warn/error). Bad labels: user IDs, request IDs, timestamps, or any value that is unique per log line.

✅ Good labels (low cardinality — few unique values):
  job="nginx"
  env="production"
  host="server1"
  level="error"

❌ Bad labels (high cardinality — millions of unique values):
  request_id="abc123def456"    ← unique per request
  user_id="12345"              ← unique per user
  timestamp="2026-07-15..."    ← never repeat this
  ip="192.168.1.100"           ← too many unique IPs

Rule of thumb: If a label value can be one of millions of unique values, it’s not a label — it’s log content. Keep it in the log line itself and filter it with |= or | json.

Common Issues and Quick Fixes

SymptomLikely CauseFix
“Data source connected” but no labels in GrafanaAlloy not yet shipping logsWait 30-60 seconds; check docker compose logs alloy for errors
http://localhost:3100 not working from GrafanaUsing localhost instead of service nameChange to http://loki:3100 in the Grafana data source config
Loki returns “too many outstanding requests”Query hits concurrency limitIncrease max_query_parallelism in loki-config or reduce dashboard panel count
Alloy can’t read Docker logs/var/run/docker.sock not mountedEnsure - /var/run/docker.sock:/var/run/docker.sock:ro is in Alloy volumes
Loki disk growing too fastRetention not configuredAdd retention_period to limits_config and enable compactor
High cardinality warning in Loki logsToo many unique label combinationsAudit your Alloy config; remove high-cardinality labels from relabeling rules
Missing auth.log or syslog entriesFile permissionsRun Alloy container as root or adjust file permissions on /var/log

Next Steps

With Grafana Loki running alongside your infrastructure:

  • Unified observability — add Loki as a second data source in your Prometheus + Grafana monitoring stack to get metrics and logs in a single dashboard
  • Security monitoring — create dashboards tracking SSH brute force attempts, UFW blocks, and failed authentication events using the LogQL queries in this guide
  • Application log parsing — extend the Alloy config to parse structured JSON logs from your Redis and MongoDB containers with the | json pipe expression
  • Log-based alerting — configure Grafana alerts using the LogQL metric queries from Step 7, and route notifications to Slack or email via the Alertmanager setup in the Prometheus + Grafana guide
  • Harden the stack — follow the Docker Container Security Best Practices guide to ensure Loki’s port 3100 is not exposed to the internet and Docker socket access is appropriately restricted
(Visited 1 times, 1 visits today)

You may also like