What LGTM actually is

LGTM is Grafana Labs' observability stack, and the acronym is a backronym that happens to be convenient. All four components share the same architecture: stateless microservices, object storage as the only durable dependency, and horizontal scalability by component.

ComponentSignalReplacesStorage model
LokiLogsElasticsearch / SplunkLabel index + compressed chunks in object storage
GrafanaVisualisationKibana / vendor UISmall relational DB for dashboards and config
TempoTracesJaeger / ZipkinTrace ID index + blocks in object storage
MimirMetricsLong-term Prometheus, Thanos, CortexTSDB blocks in object storage

In front of them sits a collector — Grafana Alloy or the OpenTelemetry Collector — that scrapes, receives, processes, and routes everything. Alloy is Grafana's distribution and speaks all four backends natively; the OTel Collector is the vendor-neutral choice. Both work; pick one and standardise on it.

The whole stack on a laptop

This compose file gives you a working end-to-end pipeline: a demo app emitting metrics, logs and traces, a collector, all four backends, and Grafana pre-provisioned with datasources and the correlation links from Parts 8 and 9.

docker-compose.yaml

services:
  minio:
    image: minio/minio:latest
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: minio
      MINIO_ROOT_PASSWORD: minio123
    ports: ["9000:9000", "9001:9001"]
    volumes: ["minio-data:/data"]
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s
      retries: 12

  createbuckets:
    image: minio/mc:latest
    depends_on:
      minio: { condition: service_healthy }
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://minio:9000 minio minio123 &&
      mc mb -p local/mimir local/loki local/tempo &&
      exit 0"

  mimir:
    image: grafana/mimir:latest
    command: ["-config.file=/etc/mimir/mimir.yaml", "-target=all"]
    volumes:
      - ./mimir/mimir.yaml:/etc/mimir/mimir.yaml:ro
      - mimir-data:/data
    ports: ["9009:9009"]
    depends_on: [createbuckets]

  loki:
    image: grafana/loki:latest
    command: ["-config.file=/etc/loki/loki.yaml"]
    volumes:
      - ./loki/loki.yaml:/etc/loki/loki.yaml:ro
      - loki-data:/loki
    ports: ["3100:3100"]
    depends_on: [createbuckets]

  tempo:
    image: grafana/tempo:latest
    command: ["-config.file=/etc/tempo/tempo.yaml"]
    volumes:
      - ./tempo/tempo.yaml:/etc/tempo/tempo.yaml:ro
      - tempo-data:/var/tempo
    ports: ["3200:3200"]
    depends_on: [createbuckets]

  alloy:
    image: grafana/alloy:latest
    command:
      - run
      - --server.http.listen-addr=0.0.0.0:12345
      - --storage.path=/var/lib/alloy/data
      - /etc/alloy/config.alloy
    volumes:
      - ./alloy/config.alloy:/etc/alloy/config.alloy:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - alloy-data:/var/lib/alloy/data
    ports:
      - "12345:12345"   # Alloy UI - genuinely useful for debugging pipelines
      - "4317:4317"     # OTLP gRPC
      - "4318:4318"     # OTLP HTTP
    depends_on: [mimir, loki, tempo]

  grafana:
    image: grafana/grafana:latest
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
      GF_FEATURE_TOGGLES_ENABLE: traceqlEditor,tracesEmbeddedFlameGraph
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
      - ./grafana/dashboards:/var/lib/grafana/dashboards:ro
    ports: ["3000:3000"]
    depends_on: [mimir, loki, tempo]

volumes:
  minio-data:
  mimir-data:
  loki-data:
  tempo-data:
  alloy-data:

One collector configuration for all three signals

alloy/config.alloy

// alloy/config.alloy

// ---------- METRICS ----------
prometheus.exporter.unix "node" { }

discovery.docker "containers" {
  host = "unix:///var/run/docker.sock"
}

prometheus.scrape "everything" {
  targets = concat(
    prometheus.exporter.unix.node.targets,
    discovery.docker.containers.targets,
  )
  scrape_interval = "15s"
  forward_to = [prometheus.relabel.cardinality_guard.receiver]
}

// Cardinality control at the collector - Part 7 in practice
prometheus.relabel "cardinality_guard" {
  forward_to = [prometheus.remote_write.mimir.receiver]

  rule {
    source_labels = ["__name__"]
    regex  = "go_gc_duration_seconds.*|promhttp_.*"
    action = "drop"
  }
  rule {
    regex  = "container_label_.*|annotation_.*"
    action = "labeldrop"
  }
}

prometheus.remote_write "mimir" {
  endpoint {
    url = "http://mimir:9009/api/v1/push"
    headers = { "X-Scope-OrgID" = "demo" }
  }
}

// ---------- LOGS ----------
loki.source.docker "containers" {
  host       = "unix:///var/run/docker.sock"
  targets    = discovery.docker.containers.targets
  forward_to = [loki.process.parse.receiver]
}

loki.process "parse" {
  forward_to = [loki.write.loki.receiver]

  stage.json {
    expressions = { level = "level", trace_id = "trace_id" }
  }
  stage.labels {
    values = { level = "" }          // bounded -> label
  }
  stage.structured_metadata {
    values = { trace_id = "" }       // unbounded -> structured metadata
  }
}

loki.write "loki" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
    headers = { "X-Scope-OrgID" = "demo" }
  }
}

// ---------- TRACES ----------
otelcol.receiver.otlp "default" {
  grpc { endpoint = "0.0.0.0:4317" }
  http { endpoint = "0.0.0.0:4318" }

  output {
    traces  = [otelcol.processor.batch.default.input]
    metrics = [otelcol.exporter.prometheus.to_mimir.input]
    logs    = [otelcol.exporter.loki.to_loki.input]
  }
}

otelcol.processor.batch "default" {
  send_batch_size = 1000
  timeout         = "5s"
  output {
    traces = [otelcol.exporter.otlp.tempo.input]
  }
}

otelcol.exporter.otlp "tempo" {
  client {
    endpoint = "tempo:4317"
    tls { insecure = true }
  }
}

otelcol.exporter.prometheus "to_mimir" {
  forward_to = [prometheus.remote_write.mimir.receiver]
}

otelcol.exporter.loki "to_loki" {
  forward_to = [loki.write.loki.receiver]
}

Alloy's UI on port 12345 renders the pipeline as a live graph with the health of every component and sample data flowing through it. When something is not arriving, that page tells you which stage dropped it in about ten seconds. Learn it early.

Wiring the correlations

The stack is only worth the effort if the signals link to each other. This provisioning file connects all three directions: metric exemplar to trace, trace to logs, and log to trace.

grafana/provisioning/datasources/datasources.yaml

apiVersion: 1
datasources:
  - name: Mimir
    type: prometheus
    uid: mimir
    url: http://mimir:9009/prometheus
    isDefault: true
    jsonData:
      httpHeaderName1: X-Scope-OrgID
      exemplarTraceIdDestinations:
        - name: trace_id
          datasourceUid: tempo
    secureJsonData:
      httpHeaderValue1: demo

  - name: Loki
    type: loki
    uid: loki
    url: http://loki:3100
    jsonData:
      httpHeaderName1: X-Scope-OrgID
      derivedFields:
        - name: TraceID
          matcherType: label
          matcherRegex: trace_id
          url: "$${__value.raw}"
          datasourceUid: tempo
    secureJsonData:
      httpHeaderValue1: demo

  - name: Tempo
    type: tempo
    uid: tempo
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        spanStartTimeShift: "-5m"
        spanEndTimeShift: "5m"
        filterByTraceID: true
      tracesToMetrics:
        datasourceUid: mimir
        queries:
          - name: "Request rate"
            query: 'sum(rate(traces_spanmetrics_calls_total{$$__tags}[5m]))'
      serviceMap:
        datasourceUid: mimir
      nodeGraph:
        enabled: true

Moving it to Kubernetes

The compose stack teaches you the moving parts. For anything beyond a laptop, use the official Helm charts and run each backend in a scalable mode rather than monolithic.

Helm installation

# Add the repo once
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

# Mimir - distributed mode
helm upgrade --install mimir grafana/mimir-distributed \
  --namespace observability --create-namespace \
  --values values/mimir.yaml

# Loki - simple scalable mode: read, write and backend targets.
# Choose this over microservices mode until you have a reason not to.
helm upgrade --install loki grafana/loki \
  --namespace observability \
  --set deploymentMode=SimpleScalable \
  --values values/loki.yaml

# Tempo - distributed
helm upgrade --install tempo grafana/tempo-distributed \
  --namespace observability \
  --values values/tempo.yaml

# Alloy as a DaemonSet for node and pod telemetry
helm upgrade --install alloy grafana/alloy \
  --namespace observability \
  --values values/alloy.yaml

helm upgrade --install grafana grafana/grafana \
  --namespace observability \
  --values values/grafana.yaml

values/mimir.yaml

# values/mimir.yaml - the settings that matter most
mimir:
  structuredConfig:
    limits:
      # The cardinality circuit breakers. Set them before you need them.
      max_global_series_per_user: 5000000
      max_global_series_per_metric: 200000
      ingestion_rate: 250000
      ingestion_burst_size: 500000
      max_label_names_per_series: 30
      compactor_blocks_retention_period: 8760h   # 1 year

    common:
      storage:
        backend: s3
        s3:
          endpoint: s3.eu-west-1.amazonaws.com
          bucket_name: mimir-prod
          region: eu-west-1

# Multi-tenancy: each team gets its own X-Scope-OrgID and its own
# limits, so one team's cardinality incident cannot take out the others.
multitenancy_enabled: true

ingester:
  replicas: 3
  zoneAwareReplication:
    enabled: true
  persistentVolume:
    size: 100Gi

querier:
  replicas: 4

store_gateway:
  replicas: 3
  zoneAwareReplication:
    enabled: true

Sizing and cost

Rough figures for planning. Measure your own workload before committing to hardware, but these get you within the right order of magnitude.

WorkloadMetricsLogsTraces
Small — 50 services, 200 pods~500k active series, 2 CPU / 8 GB per ingester~50 GB/day, 2 write + 2 read replicas~5 GB/day at 10% sampling
Medium — 300 services, 2,000 pods~5M active series, 3 ingesters x 4 CPU / 16 GB~500 GB/day, 3 write + 3 read~50 GB/day
Large — 1,000+ services50M+ series, dedicated ingester pool, zone-aware5+ TB/day, microservices mode~500 GB/day, tail sampling essential
  • Object storage is the cheap part. Compute for ingesters and queriers dominates the bill; size those carefully and let storage grow.
  • Set retention per signal. Metrics 12 months, logs 30 days, traces 7–14 days is a common and defensible split.
  • Downsample old metrics. Recording rules that aggregate away instance-level labels cut long-term series count by an order of magnitude.
  • Enable the query result cache. Dashboard queries repeat constantly and caching them is close to free.
  • Set per-tenant limits from day one. They are the only thing standing between one team's bad deploy and a platform-wide outage.

Hands-on: labs/10-lgtm-stack in the companion repo contains this entire compose stack, all configuration files, provisioned dashboards, and a make demo target that starts everything and generates realistic traffic.

Key takeaways

  • LGTM is Loki, Grafana, Tempo, Mimir - four components sharing one architecture: stateless services over object storage.
  • Run one collector, Alloy or the OTel Collector, handling all three signals. Two collectors means two places to debug.
  • Enforce cardinality control at the collector, not just at the application.
  • The correlation configuration - exemplars, derived fields, traces-to-logs - is what makes the stack worth running.
  • On Kubernetes use the Helm charts in scalable mode, not the monolithic single-binary mode.
  • Set per-tenant limits before you need them. They turn a platform outage into one team's failed writes.
  • Different retention per signal: metrics in months, logs in weeks, traces in days.

Quick answers

Do I need Mimir, or is plain Prometheus enough?

Plain Prometheus is enough for a single cluster with fewer than a few million series and retention measured in weeks. Mimir earns its complexity when you need long-term retention, a global query view across clusters, multi-tenancy with per-team limits, or high availability with deduplication. Many teams run Prometheus for scraping and remote-write to Mimir for storage.

Alloy or the OpenTelemetry Collector?

Alloy if you are committed to the Grafana stack: it handles Prometheus scraping, Loki shipping, and OTLP in one binary with a good debugging UI. The OTel Collector if you want vendor neutrality or already have an OTel-based pipeline. Alloy embeds OTel Collector components, so the two are closer than they appear.

Can I run this stack on a single node?

Yes, for development and small production workloads. Loki, Tempo and Mimir all have single-binary modes and can use local filesystem storage. The compose stack in the companion repo runs comfortably in about 4 GB of RAM. Move to distributed mode when a single node can no longer hold the ingest rate or when you need availability guarantees.

How do I migrate from an existing Prometheus?

Add remote_write to Mimir alongside your existing setup and run both in parallel. Point Grafana at Mimir for historical queries while Prometheus continues to handle scraping and alerting. Once you trust the data, move rule evaluation to the Mimir ruler and shrink local Prometheus retention. There is no cutover moment, which is the point.