Running docker logs mycontainer works for a single container on a single host. It does not work when you have 50 containers across 5 servers, one of which logged an error 3 hours ago that caused a cascade of failures you are now debugging at 2 AM. Centralized logging collects logs from every container, enriches them with metadata (container name, host, timestamp), and provides a searchable interface for querying across your entire infrastructure.

This guide covers three centralized logging stacks for Docker: the ELK Stack (Elasticsearch, Logstash, Kibana) for teams that need full-text search and analytics, Grafana Loki with Promtail for teams that want lightweight log aggregation alongside Grafana metrics, and Fluentd/Fluent Bit for flexible log routing and transformation.

Docker Logging Drivers

Before setting up a centralized stack, understand how Docker handles logs. Every container has a logging driver that determines where stdout and stderr output goes:

Driver Description docker logs
json-file Default. Writes JSON to host filesystem Yes
local Optimized local storage with rotation Yes
fluentd Sends to Fluentd collector No
gelf Sends to Graylog/Logstash via GELF No
syslog Sends to syslog daemon No
journald Sends to systemd journal Yes
none Discard all logs No
# Configure logging driver per container in Compose
services:
  app:
    image: myapp:latest
    logging:
      driver: json-file
      options:
        max-size: "50m"     # Rotate at 50MB
        max-file: "5"       # Keep 5 rotated files
        tag: "{{.Name}}"    # Add container name as tag

# Or set the default driver for all containers in /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "50m",
    "max-file": "3"
  }
}
Warning: The default json-file driver has no size limit. Without max-size and max-file options, a busy container can fill your disk with logs. Always configure log rotation, even if you are forwarding logs to a centralized system.

ELK Stack (Elasticsearch, Logstash, Kibana)

The ELK stack is the most established centralized logging solution. It provides full-text search, log analytics, and powerful visualization capabilities:

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms1g -Xmx1g"
    volumes:
      - es-data:/usr/share/elasticsearch/data
    ports:
      - "127.0.0.1:9200:9200"
    deploy:
      resources:
        limits:
          memory: 2G
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5

  logstash:
    image: docker.elastic.co/logstash/logstash:8.12.0
    container_name: logstash
    volumes:
      - ./logstash/pipeline:/usr/share/logstash/pipeline:ro
    ports:
      - "127.0.0.1:5044:5044"    # Beats input
      - "127.0.0.1:5000:5000"    # TCP input
      - "127.0.0.1:12201:12201/udp"  # GELF input
    environment:
      - "LS_JAVA_OPTS=-Xms512m -Xmx512m"
    depends_on:
      elasticsearch:
        condition: service_healthy

  kibana:
    image: docker.elastic.co/kibana/kibana:8.12.0
    container_name: kibana
    ports:
      - "127.0.0.1:5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      elasticsearch:
        condition: service_healthy

volumes:
  es-data:

Logstash Pipeline Configuration

# logstash/pipeline/docker-logs.conf
input {
  # Receive logs from Docker GELF driver
  gelf {
    port => 12201
    type => "docker"
  }

  # Receive logs from Filebeat/Fluent Bit
  beats {
    port => 5044
  }

  # Receive logs via TCP (for fluentd driver)
  tcp {
    port => 5000
    codec => json
  }
}

filter {
  # Parse JSON log messages
  if [message] =~ /^\{/ {
    json {
      source => "message"
      target => "parsed"
    }
  }

  # Extract log level
  grok {
    match => {
      "message" => [
        "%{LOGLEVEL:log_level}",
        "level=%{WORD:log_level}"
      ]
    }
    tag_on_failure => []
  }

  # Add Docker metadata
  mutate {
    add_field => {
      "environment" => "${ENVIRONMENT:production}"
    }
  }

  # Parse timestamps
  date {
    match => ["timestamp", "ISO8601", "yyyy-MM-dd HH:mm:ss"]
    target => "@timestamp"
  }
}

output {
  elasticsearch {
    hosts => ["http://elasticsearch:9200"]
    index => "docker-logs-%{+YYYY.MM.dd}"
  }
}

Configure containers to send logs to Logstash via GELF:

services:
  app:
    image: myapp:latest
    logging:
      driver: gelf
      options:
        gelf-address: "udp://logstash:12201"
        tag: "myapp"
        labels: "environment,version"

Grafana Loki + Promtail

Loki is a lightweight alternative to Elasticsearch that indexes only log metadata (labels), not the full text of every log line. This makes it dramatically cheaper to run while still providing useful log querying through Grafana:

services:
  loki:
    image: grafana/loki:2.9.4
    container_name: loki
    ports:
      - "127.0.0.1:3100:3100"
    volumes:
      - loki-data:/loki
      - ./loki/loki-config.yml:/etc/loki/local-config.yaml:ro
    command: -config.file=/etc/loki/local-config.yaml
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost:3100/ready || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5

  promtail:
    image: grafana/promtail:2.9.4
    container_name: promtail
    volumes:
      - /var/log:/var/log:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./loki/promtail-config.yml:/etc/promtail/config.yml:ro
    command: -config.file=/etc/promtail/config.yml
    depends_on:
      - loki

  grafana:
    image: grafana/grafana:10.3.1
    container_name: grafana
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro

volumes:
  loki-data:
  grafana-data:

Loki Configuration

# loki/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

schema_config:
  configs:
    - from: 2024-01-01
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

limits_config:
  retention_period: 30d
  max_query_length: 721h

compactor:
  working_directory: /loki/compactor
  compaction_interval: 10m
  retention_enabled: true
  retention_delete_delay: 2h

Promtail Configuration

# loki/promtail-config.yml
server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  # Scrape Docker container logs
  - job_name: docker
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      # Use container name as label
      - source_labels: ['__meta_docker_container_name']
        regex: '/(.*)'
        target_label: 'container'
      # Use image name as label
      - source_labels: ['__meta_docker_container_image']
        target_label: 'image'
      # Use compose service name
      - source_labels: ['__meta_docker_container_label_com_docker_compose_service']
        target_label: 'service'
      # Use compose project name
      - source_labels: ['__meta_docker_container_label_com_docker_compose_project']
        target_label: 'project'
    pipeline_stages:
      # Try to parse JSON logs
      - docker: {}
      - json:
          expressions:
            level: level
            msg: msg
            timestamp: time
      - labels:
          level:
      - timestamp:
          source: timestamp
          format: RFC3339Nano
Tip: Loki uses 10-20x less storage than Elasticsearch for the same log volume because it only indexes labels, not full text. The trade-off is that full-text search is slower (grep-like filtering rather than inverted index lookup). For most operational use cases, label-based filtering ({service="myapp"} |= "error") is sufficient and the storage savings are significant.

Fluentd and Fluent Bit

Fluentd and Fluent Bit are log processors that collect, transform, and route logs. Fluent Bit is the lightweight version (~450KB binary vs ~40MB for Fluentd):

services:
  fluent-bit:
    image: fluent/fluent-bit:2.2
    container_name: fluent-bit
    volumes:
      - ./fluent-bit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf:ro
      - ./fluent-bit/parsers.conf:/fluent-bit/etc/parsers.conf:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    ports:
      - "127.0.0.1:24224:24224"  # Forward input
    depends_on:
      - loki  # Or elasticsearch
# fluent-bit/fluent-bit.conf
[SERVICE]
    Flush        1
    Daemon       Off
    Log_Level    info
    Parsers_File parsers.conf

# Collect Docker container logs
[INPUT]
    Name              tail
    Tag               docker.*
    Path              /var/lib/docker/containers/*/*.log
    Parser            docker
    DB                /fluent-bit/db/docker.db
    Mem_Buf_Limit     50MB
    Skip_Long_Lines   On
    Refresh_Interval  10

# Also accept logs via forward protocol (fluentd logging driver)
[INPUT]
    Name   forward
    Listen 0.0.0.0
    Port   24224

# Parse JSON log messages
[FILTER]
    Name         parser
    Match        docker.*
    Key_Name     log
    Parser       json
    Reserve_Data On

# Add hostname
[FILTER]
    Name   modify
    Match  *
    Add    hostname ${HOSTNAME}

# Output to Loki
[OUTPUT]
    Name       loki
    Match      *
    Host       loki
    Port       3100
    Labels     job=fluent-bit, container=$container_name
    Auto_Kubernetes_Labels Off

# Or output to Elasticsearch
# [OUTPUT]
#     Name          es
#     Match         *
#     Host          elasticsearch
#     Port          9200
#     Index         docker-logs
#     Type          _doc
#     Suppress_Type_Name On

Structured Logging

The most effective way to improve your logging is to output structured JSON from your applications. This eliminates the need for complex parsing in Logstash or Fluent Bit:

// Go: structured logging with zap (JSON output)
logger, _ := zap.NewProduction()
defer logger.Sync()

logger.Info("user authenticated",
    zap.String("user_id", "12345"),
    zap.String("method", "oauth2"),
    zap.Duration("latency", time.Since(start)),
    zap.Int("status", 200),
)

// Output (single JSON line, easy to parse):
// {"level":"info","ts":1684234567.890,"caller":"auth/handler.go:42",
//  "msg":"user authenticated","user_id":"12345","method":"oauth2",
//  "latency":0.023,"status":200}

Structured logs allow you to filter and aggregate without regex parsing:

# Loki LogQL query examples
{service="myapp"} | json | level="error"
{service="myapp"} | json | latency > 1000
{service="myapp"} | json | status >= 500 | line_format "{{.method}} {{.path}} {{.status}}"

# Count errors per minute
sum(rate({service="myapp"} | json | level="error" [1m])) by (service)

Log Retention and Storage

Stack Storage per GB of logs/day Recommended retention
ELK ~1.5-3GB on disk 30-90 days (ILM policies)
Loki ~0.1-0.3GB on disk 30-180 days
Local JSON files ~1GB on disk 3-7 days (rotation)
# Elasticsearch Index Lifecycle Management (ILM)
PUT _ilm/policy/docker-logs-policy
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": { "max_size": "50gb", "max_age": "1d" }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": { "number_of_shards": 1 },
          "forcemerge": { "max_num_segments": 1 }
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": { "delete": {} }
      }
    }
  }
}

Alerting on Logs

# Grafana alerting rule on Loki logs
# Alert when error rate exceeds threshold
groups:
  - name: log-alerts
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate({service="myapp"} | json | level="error" [5m])) > 10
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "High error rate in {{ $labels.service }}"
          description: "Error rate is {{ $value }} errors/second"

      - alert: ContainerCrashLoop
        expr: |
          count_over_time({container=~".+"} |= "container died" [5m]) > 3
        for: 1m
        labels:
          severity: warning

Which Stack to Choose

Choose ELK when you need powerful full-text search across logs, complex aggregations for analytics, or compliance requirements that demand full log indexing. Be prepared for the resource cost: Elasticsearch needs at least 2GB RAM for a single node.

Choose Loki + Promtail when you already use Grafana for metrics (Prometheus), want minimal storage overhead, and your log querying is primarily label-based filtering. This is the best choice for most Docker deployments.

Choose Fluent Bit as a log collector that can send to either ELK or Loki (or both). It is especially useful when you need to route logs to multiple destinations or transform them before storage.

Container management platforms like usulnet provide built-in log viewing for individual containers, which complements centralized logging by giving operators quick access to recent logs without switching to Kibana or Grafana. For deeper analysis and historical queries, the centralized logging stack provides the full picture.